import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dropdown, Message, Tooltip } from "@arco-design/web-react"; import { useTranslation } from "react-i18next"; import { calculateTaskPrice, createGenTask, getAiCategoryList, getAiModelList, getAiModelPrice, getAssetDetail, getParamConfig, getUserOperationDetail, recordDepointEvent, saveUserOperation, } from "@/api/api"; import { MY_CREATIONS_REFRESH_QUERY, ROUTES } from "@/constants"; import { useComposerStore } from "@/store/useComposerStore"; import { useActiveTaskSubTypeStore, useCharacterToReferenceStore, useGlobalPromptImageStore, useMakeSameStore, useTemplateSelectStore, type GlobalPromptMediaIntent, } from "@/store/useMakeSameStore"; import "./globalPromptPanel.scss"; import { ensureSufficientPoints, createComposerNavigate, ratioChipPreviewSize, uploadComposerMedia, uploadComposerMediaFiles } from "./composerCommon"; import { ActionMimicUploadPromptSection, type ActionMimicSelectedItem } from "./composerSections/ActionMimicUploadPromptSection"; import { CommonUploadPromptSection, type ComposerMediaItem, type ComposerMediaType } from "./composerSections/CommonUploadPromptSection"; import { HeadTailUploadPromptSection } from "./composerSections/HeadTailUploadPromptSection"; import { ReferenceUploadPromptSection } from "./composerSections/ReferenceUploadPromptSection"; import { GlobalTemplateComposerShell, type GlobalTemplateComposerSnapshot, type GlobalTemplateComposerShellHandle, } from "./GlobalTemplateComposerShell/GlobalTemplateComposerShell"; import { TEMPLATE_COMPOSER_DEFAULT_PARAMS, buildTemplateComposerSnapshotFromBucket, buildTemplateComposerSnapshotFromTaskInfo, } from "./GlobalTemplateComposerShell/templateComposerPreset"; import type { AiModelItem, AspectRatioItem, ComposerParamField, GlobalPromptDraftBucket, GlobalPromptDraftStoreV2, ReferenceSubjectItem } from "./types"; import { useHorizontalWheelScroll } from "./useHorizontalWheelScroll"; import { OTHER_CATEGORY, buildPreferredParamValues, buildComposerTaskParamPayload, buildSeedanceVideoPricePayload, buildSelectedParamsFromTaskInfo, buildGenTaskModelPayload, collectTaskInfoMedia, collectRejectedComposerMediaTypes, collectRejectedMediaTypesFromFiles, countComposerImages, dimensionPayloadFromPreview, getModelUploadImageLimit, getComposerParamFieldsForModel, getResolutionSizePreview, getTaskSubTypeRow, inferMediaTypeFromFile, inferMediaTypeFromUrl, isComposerMediaItemAllowedByModelMimeList, isFileAllowedByModelMimeList, isOptionMatch, isRatioField, isRestorableMediaUrl, parseDisplayDimensions, parseDraftStore, pickModelFromTaskInfo, readMediaDurationMap, splitUploadMediaItems, } from "./utils"; import { closeAssetDetailsModal } from "@/dialog"; import { useUserStore } from "@/store/useUserStore"; /** * globalPromptPanel — 底部全局 AI 创作输入区(原 Layout 内联区块) * * ## 目录职责 * | 文件 | 职责 | * |------|------| * | globalPromptPanel.tsx | 主壳:Tab、模型/参数、上传编排、草稿、跨页 intent、提交 | * | utils.ts | 纯函数:subType 映射、媒体解析、草稿 JSON、模型参数计算 | * | composerCommon.ts | 上传接口、积分校验 | * | types.ts | 本模块 TS 类型 | * | composerSections/* | 各 subType 专属输入 UI | * | GlobalTemplateComposerShell/ | subType 999 模板创作 | * * ## 任务 subType(`activeTaskSubType`) * - 102 文生图 · 103 图生图 · 202 生视频 · 203 参考生视频 * - 204 首尾帧 · 205 动作模仿 · 999 模板(走 GlobalTemplateComposerShell) * * ## 主流程 * 1. 挂载:分类列表 → 默认 subType → 拉模型 → 回填用户草稿(getUserOperationDetail) * 2. 编辑:`syncCurrentDraftBucketToStore` → 防抖 `saveUserOperation` * 3. 跨页:MakeSame / applyMedia / templateSelect / characterToReference 经 store intent 注入 * 4. 提交:校验 → 积分 → createGenTask → 跳转「我的创作」或派发刷新事件 * * ## 展示模式 * - `floating`:Layout 底部悬浮(默认) * - `embedded`:嵌入 Create 等页,由 `embeddedVisible` 控制显隐 */ const GLOBAL_PROMPT_SAVE_DEBOUNCE_MS = 600; /** 提示词埋点 `recordDepointEvent` 防抖间隔 */ const PROMPT_DEPOINT_RECORD_DEBOUNCE_MS = 1000; /** 创作区静态图标,避免每次 render 重复 `new URL` */ const COMPOSER_ASSET = { icHelp: new URL("@/assets/images/ic_help.png", import.meta.url).href, icArrowRightGray: new URL("/src/assets/images/ic_arrow_right_gray.png", import.meta.url).href, icLink: new URL("/src/assets/images/ic_link.png", import.meta.url).href, icModel: new URL("/src/assets/images/ic_model.png", import.meta.url).href, icRefresh: new URL("/src/assets/images/ic_refresh.png", import.meta.url).href, icStarsWhite: new URL("/src/assets/images/ic_stars_white.png", import.meta.url).href, } as const; function getDropdownPopupContainer() { return document.body; } /** 接口返回的 AI 产品分类 Tab 项 */ type AiCategoryItem = { taskSubType: number; name?: string; icon?: string; id?: number | string; }; /** * 统一计算「生成」按钮上的消耗积分。 * 分支:动作模仿固定公式 · billingMethod=2 走估价接口 · 其余走 calculateTaskPrice。 */ function useGenerateCost(params: { activeModelItem: AiModelItem | null; selectedParamValues: Record; activeTaskSubType: number | null; inputVideoDurationSeconds: number; isActionMimicTask: boolean; selectedActionMimicItem: ActionMimicSelectedItem | null; }) { const { activeModelItem, selectedParamValues, activeTaskSubType, inputVideoDurationSeconds, isActionMimicTask, selectedActionMimicItem } = params; const [generateCost, setGenerateCost] = useState(0); const debounceTimerRef = useRef(null); const requestSeqRef = useRef(0); useEffect(() => { let cancelled = false; const computeGenerateCost = async () => { // 动作模仿 40 * 秒数 * 数量 if (isActionMimicTask) { const duration = Number(selectedActionMimicItem?.duration ?? 0); const batchSize = Number(selectedParamValues.batchSize ?? 1); if (!Number.isFinite(duration) || duration <= 0) { if (!cancelled) setGenerateCost(0); return; } if (!cancelled) setGenerateCost(Math.ceil(duration * 40 * batchSize)); return; } const modelParamFields = getComposerParamFieldsForModel(activeModelItem); const resolvedParamValues = buildComposerTaskParamPayload(modelParamFields, selectedParamValues); // token计费 从服务端拿积分 if (activeModelItem?.billingMethod === 2) { const seedanceParams = buildSeedanceVideoPricePayload(modelParamFields, selectedParamValues); if (!seedanceParams) { if (!cancelled) setGenerateCost(0); return; } const currentSeq = ++requestSeqRef.current; const res: any = await getAiModelPrice({ modelName: activeModelItem?.aiModelCodeAlias, estimationType: "seedance_video", seedanceVideo: { input_video_duration_seconds: inputVideoDurationSeconds, ...seedanceParams, }, }); const points = res?.data?.estimated_points ?? 0; const batchSize = Number(resolvedParamValues.batchSize ?? 1); if (!cancelled && currentSeq === requestSeqRef.current) { setGenerateCost(Math.ceil(points * batchSize)); } return; } // 其他任务 走后端价格计算接口。 if (!activeModelItem?.code) { if (!cancelled) setGenerateCost(0); return; } const currentSeq = ++requestSeqRef.current; const res: any = await calculateTaskPrice({ subType: activeTaskSubType, aiModelName: activeModelItem?.name, aiPlatform: activeModelItem?.code, aiModelId: activeModelItem?.id, model: activeModelItem?.code, aiModelCode: activeModelItem?.code, aiModelCodeAlias: activeModelItem?.aiModelCodeAlias, type: getTaskSubTypeRow(activeTaskSubType)?.type, ...resolvedParamValues, }); const taskPrice = Number(res?.data?.taskPrice ?? 0); if (!cancelled && currentSeq === requestSeqRef.current) { setGenerateCost(Number.isFinite(taskPrice) && taskPrice > 0 ? Math.ceil(taskPrice) : 0); } }; if (debounceTimerRef.current != null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } debounceTimerRef.current = window.setTimeout(() => { void computeGenerateCost().catch(() => { if (!cancelled) setGenerateCost(0); }); }, 250); return () => { cancelled = true; if (debounceTimerRef.current != null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; } }; }, [activeModelItem, activeTaskSubType, inputVideoDurationSeconds, isActionMimicTask, selectedActionMimicItem, selectedParamValues]); return generateCost; } /** * 全局创作面板组件。 * * @param mode `floating` 悬浮于 Layout 底部;`embedded` 嵌入子页面 * @param embeddedVisible embedded 模式下是否展开 * @param pathname / search / navigateTo 可选注入,便于 embedded 场景不依赖 window.location */ export type GlobalPromptPanelMode = "floating" | "embedded"; type GlobalPromptPanelProps = { mode?: GlobalPromptPanelMode; embeddedVisible?: boolean; pathname?: string; search?: string; navigateTo?: (to: { pathname: string; search?: string }, options?: { replace?: boolean }) => void; }; export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false, pathname, search, navigateTo, }: GlobalPromptPanelProps) { const setUser = useUserStore((s) => s.setUser); // ==================== 组件基础上下文 ==================== const { t } = useTranslation(); /** 路由信息:优先使用外部注入,缺省回退到 window.location。 */ const currentPathname = pathname ?? globalThis?.location?.pathname ?? ""; const currentSearch = search ?? globalThis?.location?.search ?? ""; const navigateToMyCreations = useMemo(() => createComposerNavigate(navigateTo), [navigateTo]); // ==================== 本地状态:面板、模型、输入 ==================== const [aiCategories, setAiCategories] = useState([]); const [activeTaskSubType, setActiveTaskSubType] = useState(null); // 当前产品分类 ID const [aiModels, setAiModels] = useState([]); // AI 模型列表 const [activeModelItem, setActiveModelItem] = useState(null); // 当前选中模型 const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); // 底部面板折叠状态 const composerTabsWheelRef = useHorizontalWheelScroll({ enabled: !isComposerCollapsed }); const composerBodyWheelRef = useHorizontalWheelScroll({ enabled: true, ignoreWithin: "textarea, .arco-textarea, .referenceUploadPromptSection__promptEditor, .referenceUploadPromptSection__promptEditorWrap", }); const [modelMenuOpen, setModelMenuOpen] = useState(false); // 模型下拉开关 const [paramMenuOpen, setParamMenuOpen] = useState(false); // 参数下拉开关 const [selectedParamValues, setSelectedParamValues] = useState>({}); // 已选参数值 const [chatPrompt, setChatPrompt] = useState(""); // 输入框内容 const activeTaskSubTypeRef = useRef(activeTaskSubType); activeTaskSubTypeRef.current = activeTaskSubType; const recordPromptDepointTimerRef = useRef(null); const handleChatPromptChange = useCallback((next: string) => { setChatPrompt(next); if (recordPromptDepointTimerRef.current != null) { window.clearTimeout(recordPromptDepointTimerRef.current); recordPromptDepointTimerRef.current = null; } recordPromptDepointTimerRef.current = window.setTimeout(() => { recordPromptDepointTimerRef.current = null; const subtype = activeTaskSubTypeRef.current ?? 0; void recordDepointEvent({ eventType: "user_operation_prompt", targetId: '0', extra: { subtype, chatPrompt: next, }, }).catch(() => { /* 埋点失败不影响输入 */ }); }, 1000); }, []); useEffect(() => { return () => { if (recordPromptDepointTimerRef.current != null) { window.clearTimeout(recordPromptDepointTimerRef.current); recordPromptDepointTimerRef.current = null; } }; }, []); const supportedUploadMimePrefixes = useMemo(() => { const prefixes: string[] = []; if (activeModelItem?.isSupportImages ?? true) prefixes.push("image/"); if (activeModelItem?.isSupportVideos) prefixes.push("video/"); if (activeModelItem?.isSupportAudios) prefixes.push("audio/"); return prefixes; }, [activeModelItem?.isSupportAudios, activeModelItem?.isSupportImages, activeModelItem?.isSupportVideos]); /** 系统文件框对仅有 `audio/*` 时可能不展示 .mp3,需并列写出扩展名(含大写 .MP3)。 */ const uploadAccept = useMemo(() => { const parts: string[] = []; if (activeModelItem?.isSupportImages ?? true) { parts.push("image/*", ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"); } if (activeModelItem?.isSupportVideos) { parts.push("video/*", ".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"); } if (activeModelItem?.isSupportAudios) { parts.push("audio/*", ".mp3", ".MP3", ".m4a", ".wav", ".aac", ".ogg", ".flac", ".opus"); } return parts.join(","); }, [activeModelItem?.isSupportAudios, activeModelItem?.isSupportImages, activeModelItem?.isSupportVideos]); const [usageTutorialPageUrl, setUsageTutorialPageUrl] = useState(""); useEffect(() => { void getParamConfig() .then((res) => { setUsageTutorialPageUrl(res.systemConfig.usageTutorialPageUrl ?? ""); }) .catch(() => { setUsageTutorialPageUrl(""); }); }, []); // ==================== 全局 store:跨页面消息与控制 ==================== const makeSameIntent = useMakeSameStore((s) => s.intent); // 制作同款意图 const makeSameTemplateIntentSeq = useMakeSameStore((s) => (s.intent?.mode === "template" ? s.intent.seq : 0)); /** 仅 seq 变化时消费一次,避免 intent effect 因 composer 状态变化反复触发 */ const applyMediaIntentSeq = useGlobalPromptImageStore((s) => s.intent?.seq ?? 0); const activeTaskSubTypeIntent = useActiveTaskSubTypeStore((s) => s.intent); // 当前产品分类意图 const applyCharacterIntent = useCharacterToReferenceStore((s) => s.intent); // 应用角色到参考意图 const templateSelectIntentSeq = useTemplateSelectStore((s) => s.intent?.seq ?? 0); // ==================== 引用与流程状态:上传、并发控制、草稿缓存 ==================== const [mediaItems, setMediaItems] = useState([]); // 已上传媒体列表 const [selectedActionMimicItem, setSelectedActionMimicItem] = useState(null); const [templateVideoMaterialId, setTemplateVideoMaterialId] = useState(null); const [referenceStructure, setReferenceStructure] = useState<{ referenceSubjectList: ReferenceSubjectItem[]; }>({ referenceSubjectList: [], }); const pendingReferenceCharacterRef = useRef<{ id: number | string; title: string; threeViewImage: string } | null>(null); const applyCharacterToReferenceHandlerRef = useRef<((payload: { id: number | string; title: string; threeViewImage: string }) => void) | null>(null); const pendingHeadTailSlotRef = useRef<0 | 1 | null>(null); // 当前要替换的槽位(0:首帧,1:尾帧) /** 制作同款已手动拿过该 subType 模型时,按 subType 精确跳过一次 effect 拉取。 */ const skipModelFetchForSubTypeRef = useRef(null); /** 防止 applyMedia intent 在 StrictMode 下重复消费同一 seq */ const applyMediaIntentProcessingSeqRef = useRef(0); /** 创建任务流程进行中,防止重复点击重复提交(ref 同步挡并发,state 驱动按钮 disabled) */ const createGenTaskInFlightRef = useRef(false); const [isCreatingGenTask, setIsCreatingGenTask] = useState(false); const templateApplyMediaHandlerRef = useRef< ((urls: string[], mode: "append" | "replace") => void) | null >(null); /** * 制作同款完成回填后自动创建任务。 */ const pendingAutoCreateAfterMakeSameRef = useRef(false); /** subType -> 模型列表缓存,减少同参数重复请求。 */ const aiModelCacheRef = useRef>(new Map()); const templateComposerRef = useRef(null); const [templateDraftSnapshot, setTemplateDraftSnapshot] = useState({ selectedTemplateId: null, images: [], selectedParamValues: { ...TEMPLATE_COMPOSER_DEFAULT_PARAMS }, }); const templateDraftRef = useRef(templateDraftSnapshot); /** 与 state 同步,供 Tab 切换与 syncStore 读取,避免时序导致读到旧快照。 */ templateDraftRef.current = templateDraftSnapshot; const composerChromeIntent = useComposerStore((s) => s.intent); const requestComposerCollapse = useComposerStore((s) => s.requestComposerCollapse); const hasLoadedUserOperationRef = useRef(false); /** 如果初始化阶段已处理外部意图,忽略晚到的草稿回填覆盖。 */ const hasExternalInitIntentRef = useRef(false); const draftStoreRef = useRef({ version: 2, currentProductType: null, draftsBySubType: {}, }); const latestSavedDraftStoreRef = useRef(""); /** 防抖保存 timer 触发时读取最新合并结果,避免 600ms 窗口内状态已变却写入旧快照 */ const syncCurrentDraftBucketToStoreRef = useRef<(override?: { subType?: number | null; templateSnapshot?: GlobalTemplateComposerSnapshot; }) => GlobalPromptDraftStoreV2>(() => draftStoreRef.current); /** 从草稿桶切回某分类后,等 `aiModels` 就绪再恢复模型与下方参数预设。 */ const pendingDraftComposerRestoreRef = useRef<{ subType: number; modelId: string | number | null; paramValues: Record; } | null>(null); const isHeadTailTask = activeTaskSubType === 204;//首尾帧 const isReferenceTask = activeTaskSubType === 203;//参考生视频 const isActionMimicTask = activeTaskSubType === 205;//动作模仿 const isTemplateTask = activeTaskSubType === 999;//模板 // ==================== 派生数据:模型上传能力 ==================== /** 当前模型对图片数量的上限;null 表示不限制图片张数。 */ const modelUploadImageLimit = useMemo(() => getModelUploadImageLimit(activeModelItem), [activeModelItem]); /** 模型是否支持上传视频/音频(此类媒体不做数量上限,仅图片受 uploadImageLimit 约束)。 */ const allowNonImageUpload = Boolean(activeModelItem?.isSupportVideos || activeModelItem?.isSupportAudios); const composerMediaUploadEnabled = supportedUploadMimePrefixes.length > 0; const taskSubTypePlaceholder = useMemo(() => { const row = getTaskSubTypeRow(activeTaskSubType); if (!row || !("placeholder" in row)) return undefined; return t(row.placeholder); }, [activeTaskSubType, t]); const handleComposerChromeExpand = useCallback(() => { setIsComposerCollapsed(false); }, []); const handleTemplateApplyMediaHandlerRegister = useCallback( (handler: ((urls: string[], mode: "append" | "replace") => void) | null) => { templateApplyMediaHandlerRef.current = handler; }, [], ); const applyDraftBucketToComposer = useCallback((subType: number | null, store: GlobalPromptDraftStoreV2) => { if (subType == null) return; const bucket = store.draftsBySubType[String(subType)] ?? {}; if (subType === 999) { pendingDraftComposerRestoreRef.current = null; const snapshot = buildTemplateComposerSnapshotFromBucket(bucket); setTemplateDraftSnapshot(snapshot); templateComposerRef.current?.setSnapshot(snapshot); return; } pendingDraftComposerRestoreRef.current = { subType, modelId: bucket.selectedAiModelId ?? null, paramValues: bucket.selectedParamValues && typeof bucket.selectedParamValues === "object" && !Array.isArray(bucket.selectedParamValues) ? { ...bucket.selectedParamValues } : {}, }; pendingHeadTailSlotRef.current = null; setChatPrompt(bucket.chatPrompt ?? ""); setMediaItems(Array.isArray(bucket.mediaItems) ? bucket.mediaItems : []); setSelectedActionMimicItem(null); setTemplateVideoMaterialId(null); if (subType === 203) { setReferenceStructure({ referenceSubjectList: Array.isArray(bucket.referenceSubjectList) ? bucket.referenceSubjectList : [], }); return; } setReferenceStructure({ referenceSubjectList: [] }); if (subType === 205) { const officialId = bucket.actionMimicTemplateVideoMaterialId; if (officialId != null && officialId !== "") { setTemplateVideoMaterialId(officialId); } return; } }, []); const syncCurrentDraftBucketToStore = useCallback( (override?: { subType?: number | null; templateSnapshot?: GlobalTemplateComposerSnapshot }) => { const subType = override?.subType ?? activeTaskSubType; if (subType == null) return draftStoreRef.current; /** 999 优先读子组件实时快照;onSnapshotChange 有 200ms 防抖,仅用 state 会在保存窗口内丢参考图/参数。 */ const templateSnapshot = override?.templateSnapshot ?? (subType === 999 ? (templateComposerRef.current?.getSnapshot() ?? templateDraftSnapshot) : templateDraftRef.current); const key = String(subType); const composerModelParams: Pick = { selectedAiModelId: activeModelItem?.id ?? null, selectedParamValues: { ...selectedParamValues }, }; const bucket: GlobalPromptDraftBucket = subType === 999 ? { selectedTemplateId: templateSnapshot.selectedTemplateId ?? null, templateImages: (templateSnapshot.images ?? []).filter((url) => isRestorableMediaUrl(url)), selectedParamValues: { ...TEMPLATE_COMPOSER_DEFAULT_PARAMS, ...(templateSnapshot.selectedParamValues && typeof templateSnapshot.selectedParamValues === "object" && !Array.isArray(templateSnapshot.selectedParamValues) ? templateSnapshot.selectedParamValues : {}), }, updatedAt: Date.now(), } : subType === 205 ? { chatPrompt, mediaItems: mediaItems.filter((item) => isRestorableMediaUrl(item.url)), actionMimicTemplateVideoMaterialId: selectedActionMimicItem?.type === "官方示例" ? selectedActionMimicItem.id : null, ...composerModelParams, updatedAt: Date.now(), } : { chatPrompt, mediaItems: mediaItems.filter((item) => isRestorableMediaUrl(item.url)), referenceSubjectList: subType === 203 ? referenceStructure.referenceSubjectList : [], ...composerModelParams, updatedAt: Date.now(), }; const nextStore: GlobalPromptDraftStoreV2 = { version: 2, currentProductType: subType, draftsBySubType: { ...draftStoreRef.current.draftsBySubType, [key]: { ...draftStoreRef.current.draftsBySubType[key], ...bucket, }, }, }; draftStoreRef.current = nextStore; return nextStore; }, [ activeTaskSubType, activeModelItem?.id, chatPrompt, mediaItems, referenceStructure.referenceSubjectList, selectedActionMimicItem, selectedParamValues, templateDraftSnapshot, ], ); syncCurrentDraftBucketToStoreRef.current = syncCurrentDraftBucketToStore; // ==================== 交互处理:上传入口与图片管理 ==================== /** 首尾帧:写入指定槽位并展开面板 */ const commitHeadTailSlotItem = useCallback((item: ComposerMediaItem) => { const targetSlot = pendingHeadTailSlotRef.current ?? 0; setMediaItems((prev) => { const next = [...prev]; next[targetSlot] = item; return next; }); pendingHeadTailSlotRef.current = null; setIsComposerCollapsed(false); }, []); /** 当前模型不支持某类媒体时,按类型提示(每类最多一条)。 */ const warnUnsupportedComposerMediaTypes = useCallback( (rejectedTypes: ComposerMediaType[]) => { for (const type of rejectedTypes) { if (type === "video") Message.warning(t("composer.unsupportedUploadVideo")); else if (type === "audio") Message.warning(t("composer.unsupportedUploadAudio")); else if (type === "image") Message.warning(t("composer.unsupportedUploadImage")); } }, [t], ); /** 按模型图片上限与 mime 能力筛选可接受的媒体项,并在超限时提示。 */ const pickAcceptedComposerMediaItems = useCallback( (prev: ComposerMediaItem[], incoming: ComposerMediaItem[]) => { let imagesLeft = modelUploadImageLimit == null ? Number.POSITIVE_INFINITY : Math.max(0, modelUploadImageLimit - countComposerImages(prev)); const accepted: ComposerMediaItem[] = []; let imageLimitWarned = false; const rejectedTypes = collectRejectedComposerMediaTypes(incoming, supportedUploadMimePrefixes); for (const item of incoming) { if (!isComposerMediaItemAllowedByModelMimeList(item, supportedUploadMimePrefixes)) { continue; } if (item.type === "image" && imagesLeft <= 0) { if (!imageLimitWarned && modelUploadImageLimit != null) { Message.warning(t("composer.maxUploadReached", { max: modelUploadImageLimit })); imageLimitWarned = true; } continue; } accepted.push(item); if (item.type === "image") imagesLeft -= 1; } if (rejectedTypes.length > 0) { warnUnsupportedComposerMediaTypes(rejectedTypes); } if (accepted.length < incoming.length && accepted.length > 0 && !imageLimitWarned && modelUploadImageLimit != null) { Message.warning(t("composer.maxUploadTrimmed", { max: modelUploadImageLimit, count: accepted.length })); } return accepted; }, [modelUploadImageLimit, supportedUploadMimePrefixes, t, warnUnsupportedComposerMediaTypes], ); /** 非首尾帧:根据待上传文件计算可接受数量;满额则提示并返回 null */ const resolveBatchUploadCapacity = useCallback( (files: File[]) => { if (files.length === 0) return 0; const pseudoItems = files.map((file) => ({ type: inferMediaTypeFromFile(file), url: "" } as ComposerMediaItem)); const accepted = pickAcceptedComposerMediaItems(mediaItems, pseudoItems); if (accepted.length === 0) { setIsComposerCollapsed(false); return null; } if (accepted.length < files.length && accepted.length > 0 && modelUploadImageLimit != null) { Message.warning(t("composer.maxUploadTrimmed", { max: modelUploadImageLimit, count: accepted.length })); } return accepted.length; }, [mediaItems, modelUploadImageLimit, pickAcceptedComposerMediaItems, t], ); const appendUploadedMediaItems = useCallback((uploadedItems: ComposerMediaItem[]) => { if (uploadedItems.length === 0) return; setMediaItems((prev) => { const accepted = pickAcceptedComposerMediaItems(prev, uploadedItems); if (accepted.length === 0) { setIsComposerCollapsed(false); return prev; } setIsComposerCollapsed(false); return [...prev, ...accepted]; }); }, [pickAcceptedComposerMediaItems]); /** 跨页 replace:整表替换为传入媒体(仍受总槽位与模型图片上限约束) */ const replaceUploadedMediaItems = useCallback( (nextItems: ComposerMediaItem[]) => { const restorable = nextItems.filter((item) => isRestorableMediaUrl(item.url)); const accepted = pickAcceptedComposerMediaItems([], restorable); if (accepted.length === 0) return; setMediaItems(accepted); setIsComposerCollapsed(false); }, [pickAcceptedComposerMediaItems], ); /** 统一处理本地文件上传(input / drop 复用) */ const uploadMediaFiles = useCallback(async (files: File[]) => { try { const rejectedTypes = collectRejectedMediaTypesFromFiles(files, supportedUploadMimePrefixes); if (rejectedTypes.length > 0) { warnUnsupportedComposerMediaTypes(rejectedTypes); } const acceptedFiles = files.filter((file) => isFileAllowedByModelMimeList(file, supportedUploadMimePrefixes)); if (acceptedFiles.length === 0) return; const durationMap = await readMediaDurationMap(acceptedFiles); if (isHeadTailTask) { const firstFile = acceptedFiles[0]; // 首尾帧模式一次只处理一个文件,并写入当前选中的槽位。 const first = await uploadComposerMedia(firstFile); const headTailType = inferMediaTypeFromFile(firstFile); commitHeadTailSlotItem({ type: headTailType, url: first.url, thumbUrl: first.thumbUrl, duration: durationMap.get(firstFile) }); return; } const availableCount = resolveBatchUploadCapacity(acceptedFiles); if (availableCount == null) return; const uploadTargets = acceptedFiles.slice(0, availableCount); const uploaded = await uploadComposerMediaFiles(uploadTargets); const uploadedItems = uploaded.map((item, idx) => { const file = uploadTargets[idx]!; return { type: inferMediaTypeFromFile(file), url: item.url, thumbUrl: item.thumbUrl, duration: durationMap.get(file) } as ComposerMediaItem; }); appendUploadedMediaItems(uploadedItems); } catch { Message.error(t("composer.uploadFailed")); } }, [appendUploadedMediaItems, commitHeadTailSlotItem, isHeadTailTask, resolveBatchUploadCapacity, supportedUploadMimePrefixes, t, warnUnsupportedComposerMediaTypes]); /** 处理拖拽进来的媒体 URL */ const appendMediaUrls = useCallback((rawUrls: string[]) => { const parsedItems = Array.from(new Set(rawUrls.map((item) => item.trim()).filter(isRestorableMediaUrl))).map( (url) => ({ type: inferMediaTypeFromUrl(url), url } as ComposerMediaItem), ); const rejectedTypes = collectRejectedComposerMediaTypes(parsedItems, supportedUploadMimePrefixes); if (rejectedTypes.length > 0) { warnUnsupportedComposerMediaTypes(rejectedTypes); } const mediaItemsFromUrls = parsedItems.filter((item) => isComposerMediaItemAllowedByModelMimeList(item, supportedUploadMimePrefixes), ); if (mediaItemsFromUrls.length === 0) return; if (isHeadTailTask) { const first = mediaItemsFromUrls[0]; if (first) commitHeadTailSlotItem(first); return; } appendUploadedMediaItems(mediaItemsFromUrls); }, [appendUploadedMediaItems, commitHeadTailSlotItem, isHeadTailTask, supportedUploadMimePrefixes, warnUnsupportedComposerMediaTypes]); /** 选择本地图片:立即上传,成功后回显远端地址 */ const handleUploadInputChange = useCallback((evt: React.ChangeEvent) => { const fileList = evt.target.files; if (!fileList || fileList.length === 0) return; void uploadMediaFiles(Array.from(fileList)); evt.target.value = ""; }, [uploadMediaFiles]); /** 拖拽悬停 / 离开 / 放下 */ const handleUploadAreaDrop = useCallback((evt: React.DragEvent) => { evt.preventDefault(); evt.stopPropagation(); const fileList = evt.dataTransfer?.files; if (fileList && fileList.length > 0) { void uploadMediaFiles(Array.from(fileList)); return; } const uriListRaw = evt.dataTransfer.getData("text/uri-list"); const plainTextRaw = evt.dataTransfer.getData("text/plain"); const uriList = uriListRaw .split("\n") .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")); const plainList = plainTextRaw ? [plainTextRaw.trim()] : []; appendMediaUrls(Array.from(new Set([...uriList, ...plainList]))); }, [appendMediaUrls, uploadMediaFiles]); /** 删除指定位置图片 */ const handleRemoveMedia = useCallback((targetIdx: number) => { setMediaItems((prev) => prev.filter((_, idx) => idx !== targetIdx)); }, []); /** 按 subjectId 恢复被剪切掉的图片,确保标签编号与图片顺序对齐。 */ const handleRestoreSubjectImages = useCallback((items: Array<{ subjectId: number; type: "image" | "video" | "audio"; url: string; thumbUrl?: string; duration?: number }>) => { if (!Array.isArray(items) || items.length === 0) return; setMediaItems((prev) => { let next = [...prev]; const sorted = [...items] .filter((item) => Number.isFinite(item.subjectId) && item.subjectId > 0 && Boolean(item.url)) .sort((a, b) => a.subjectId - b.subjectId); sorted.forEach((item) => { let insertIdx = Math.min(Math.max(item.subjectId - 1, 0), next.length); const existedIdx = next.findIndex((mediaItem) => mediaItem.url === item.url && mediaItem.type === item.type); if (existedIdx === insertIdx) return; if (existedIdx >= 0) { next.splice(existedIdx, 1); if (existedIdx < insertIdx) insertIdx -= 1; } next.splice(insertIdx, 0, { type: item.type, url: item.url, thumbUrl: item.thumbUrl, duration: item.duration }); }); if (modelUploadImageLimit == null) return next; let imageCount = 0; return next.filter((item) => { if (item.type !== "image") return true; if (imageCount >= modelUploadImageLimit) return false; imageCount += 1; return true; }); }); }, [modelUploadImageLimit]); /** * 清空非模板模式的编辑态。 * 这样可以让「重置」和「切换任务类型」共用同一套状态重置逻辑,避免分叉。 */ const resetNonTemplateComposerState = useCallback(() => { setMediaItems([]); setChatPrompt(""); setSelectedActionMimicItem(null); setTemplateVideoMaterialId(null); setReferenceStructure({ referenceSubjectList: [], }); pendingHeadTailSlotRef.current = null; }, []); /** 重置输入与上传内容 */ const handleResetComposer = useCallback(() => { if (isTemplateTask) { templateComposerRef.current?.reset(); setTemplateDraftSnapshot({ selectedTemplateId: null, images: [], selectedParamValues: { ...TEMPLATE_COMPOSER_DEFAULT_PARAMS }, }); return; } resetNonTemplateComposerState(); }, [isTemplateTask, resetNonTemplateComposerState]); // ==================== 面板 UI 同步:显示/折叠状态 ==================== useEffect(() => { if (!composerChromeIntent) return; setIsComposerCollapsed(composerChromeIntent.action === "collapse"); }, [composerChromeIntent]); // ==================== 初始化数据:分类与模型 ==================== useEffect(() => { void getAiCategoryList() .then((res: { data?: { list?: unknown[] } }) => { const rawList = res.data?.list ? [...res.data.list, ...OTHER_CATEGORY] : []; const list = rawList .map((item): AiCategoryItem | null => { if (!item || typeof item !== "object") return null; const row = item as Record; const subTypeNum = Number(row.taskSubType); if (!Number.isFinite(subTypeNum)) return null; return { ...row, taskSubType: subTypeNum, name: typeof row.name === "string" ? row.name : undefined, icon: typeof row.icon === "string" ? row.icon : undefined, id: typeof row.id === "number" || typeof row.id === "string" ? row.id : undefined, }; }) .filter((item): item is AiCategoryItem => item != null && item.taskSubType !== 106); setAiCategories(list); }) .catch(() => { setAiCategories([]); }); }, []); /** 初始化当前产品分类 ID */ useEffect(() => { if (aiCategories.length === 0) return; setActiveTaskSubType((prev) => { const prevNum = prev == null ? null : Number(prev); if (prevNum != null && Number.isFinite(prevNum) && aiCategories.some((row) => Number(row.taskSubType) === prevNum)) return prevNum; return aiCategories[0].taskSubType; }); }, [aiCategories]); /** 按 subType 拉模型列表并缓存;`displayDimensions` 从 JSON 字符串解析为对象。 */ const fetchAiModelsForSubType = useCallback(async (subType: number): Promise => { const cached = aiModelCacheRef.current.get(subType); if (cached) return cached; const res = (await getAiModelList({ subType })) as { data?: AiModelItem[] }; const list = Array.isArray(res.data) ? res.data : []; list.forEach((item) => { item.displayDimensions = parseDisplayDimensions(item.displayDimensions); }); aiModelCacheRef.current.set(subType, list); return list; }, []); const applyAiModelsList = useCallback((list: AiModelItem[]) => { setAiModels(list); const firstModel = list[0] ?? null; setActiveModelItem(firstModel); setSelectedParamValues(buildPreferredParamValues(getComposerParamFieldsForModel(firstModel), {})); }, []); /** 拉模型并写状态;制作同款可选择只取列表不落地,避免中间态覆盖。 */ const loadModelsForSubType = useCallback( async (subType: number, options?: { applyToState?: boolean }) => { const shouldApplyToState = options?.applyToState ?? true; try { const list = await fetchAiModelsForSubType(subType); if (shouldApplyToState) { applyAiModelsList(list); } return list; } catch { if (shouldApplyToState) { applyAiModelsList([]); } return []; } }, [applyAiModelsList, fetchAiModelsForSubType], ); /** * 跨页 applyMedia intent 统一入口(对齐制作同款:先占住 subType 拉模,再写媒体/提示词,保证积分重算)。 * 仅由 store seq 触发一次,不订阅易变的 composer 状态。 */ const applyExternalMediaIntent = useCallback( async (intent: GlobalPromptMediaIntent) => { hasExternalInitIntentRef.current = true; const targetSubType = intent.subType ?? activeTaskSubTypeRef.current; const normalizedItems = intent.mediaItems .map((item) => ({ type: item.type ?? inferMediaTypeFromUrl(item.url), url: item.url.trim(), thumbUrl: item.thumbUrl, duration: item.duration, })) .filter((item) => isRestorableMediaUrl(item.url)) as ComposerMediaItem[]; const subTypeChanges = targetSubType != null && targetSubType !== activeTaskSubTypeRef.current; const aiModelCode = intent.aiModelCode?.trim() ?? ""; const willLoadModelByCode = Boolean(aiModelCode) && targetSubType != null && targetSubType !== 999; if (subTypeChanges && targetSubType != null) { const latestStore = syncCurrentDraftBucketToStore(); applyDraftBucketToComposer(targetSubType, latestStore); if (willLoadModelByCode) { skipModelFetchForSubTypeRef.current = targetSubType; } setActiveTaskSubType(targetSubType); } if (intent.prompt !== undefined) { setChatPrompt(intent.prompt); } if (normalizedItems.length > 0 && targetSubType != null) { const mediaMode = intent.mediaMode; if (targetSubType === 999) { templateApplyMediaHandlerRef.current?.( normalizedItems.map((item) => item.url), mediaMode, ); } else if (targetSubType === 204) { if (mediaMode === "replace") { replaceUploadedMediaItems(normalizedItems.slice(0, 2)); } else if (normalizedItems.length === 1) { commitHeadTailSlotItem(normalizedItems[0]!); } else { setMediaItems(normalizedItems.slice(0, 2)); setIsComposerCollapsed(false); } } else if (mediaMode === "replace") { replaceUploadedMediaItems(normalizedItems); } else { appendUploadedMediaItems(normalizedItems); } } if (willLoadModelByCode && targetSubType != null) { skipModelFetchForSubTypeRef.current = targetSubType; try { const list = await loadModelsForSubType(targetSubType, { applyToState: false }); const model = pickModelFromTaskInfo(list, { aiModelCode }); setAiModels(list); setActiveModelItem(model); const fields = getComposerParamFieldsForModel(model); setSelectedParamValues(buildPreferredParamValues(fields, {})); } catch { applyAiModelsList([]); } } setIsComposerCollapsed(false); }, [ appendUploadedMediaItems, applyAiModelsList, applyDraftBucketToComposer, commitHeadTailSlotItem, loadModelsForSubType, replaceUploadedMediaItems, syncCurrentDraftBucketToStore, ], ); /** 写入模板 snapshot 并切到 999(子组件未挂载时靠 initialSnapshot 承接)。 */ const activateTemplateComposer = useCallback((snapshot: GlobalTemplateComposerSnapshot) => { setTemplateDraftSnapshot(snapshot); templateComposerRef.current?.setSnapshot(snapshot); setActiveTaskSubType(999); setIsComposerCollapsed(false); }, []); /** subType 变化时拉模型;制作同款会先标记 subType,避免与本 effect 重复请求。 */ useEffect(() => { if (activeTaskSubType == null) return; if (skipModelFetchForSubTypeRef.current === activeTaskSubType) { skipModelFetchForSubTypeRef.current = null; return; } let cancelled = false; void (async () => { try { const list = await fetchAiModelsForSubType(activeTaskSubType); if (cancelled) return; const pending = pendingDraftComposerRestoreRef.current; if (pending && pending.subType === activeTaskSubType && list.length > 0) { pendingDraftComposerRestoreRef.current = null; const model = pickModelFromTaskInfo(list, { aiModelId: pending.modelId } as Record); setAiModels(list); setActiveModelItem(model); const fields = getComposerParamFieldsForModel(model); setSelectedParamValues(buildPreferredParamValues(fields, pending.paramValues)); } else { applyAiModelsList(list); } } catch { if (!cancelled) applyAiModelsList([]); } })(); return () => { cancelled = true; }; }, [activeTaskSubType, applyAiModelsList, fetchAiModelsForSubType]); // ==================== 跨页联动:制作同款、模板选择、外部追加图片 ==================== /** 探索页「制作同款」等入口通过 store 传入作品 id,在此统一处理 */ const applyMakeSameFromExplore = useCallback( async (postId: number | string, options?: { autoCreateAfterFill?: boolean }) => { hasExternalInitIntentRef.current = true; pendingDraftComposerRestoreRef.current = null; syncCurrentDraftBucketToStore(); const res: any = await getAssetDetail(postId); const asset = res?.data; if (!asset) return; const taskInfo = asset.taskInfo as Record | undefined; const mergedMedia = collectTaskInfoMedia(taskInfo); setChatPrompt(typeof taskInfo?.chatPrompt === "string" ? taskInfo.chatPrompt : ""); const subType = Number(taskInfo?.subType ?? 102); const safeSubType = Number.isFinite(subType) ? subType : 102; if (safeSubType === 205) { setMediaItems(mergedMedia.filter((item) => item.type !== "video")); setSelectedActionMimicItem(null); setTemplateVideoMaterialId( typeof taskInfo?.templateVideoMaterialId === "number" || typeof taskInfo?.templateVideoMaterialId === "string" ? taskInfo.templateVideoMaterialId : null, ); } else { setMediaItems(mergedMedia); setTemplateVideoMaterialId(null); } skipModelFetchForSubTypeRef.current = safeSubType; setActiveTaskSubType(safeSubType); const list = await loadModelsForSubType(safeSubType, { applyToState: false }); const model = pickModelFromTaskInfo(list, taskInfo); setAiModels(list); setActiveModelItem(model); const fields = getComposerParamFieldsForModel(model); setSelectedParamValues(buildPreferredParamValues(fields, buildSelectedParamsFromTaskInfo(taskInfo, model))); if (options?.autoCreateAfterFill) { pendingAutoCreateAfterMakeSameRef.current = true; } else { setIsComposerCollapsed(false); } }, [loadModelsForSubType, syncCurrentDraftBucketToStore], ); useEffect(() => { if (!makeSameIntent) return; if (makeSameIntent.mode === "template") return; const { postId, autoCreateAfterFill } = makeSameIntent; if (postId == null) return; void (async () => { try { await applyMakeSameFromExplore(postId, { autoCreateAfterFill }); } finally { useMakeSameStore.getState().clearPendingMakeSame(); } })(); }, [makeSameIntent, applyMakeSameFromExplore]); /** 任意页面通过 store 请求切换当前产品分类(与 Tab 切换同源,后续由 activeTaskSubType 的 effect 拉模型) */ useEffect(() => { if (!activeTaskSubTypeIntent) return; hasExternalInitIntentRef.current = true; const { subType } = activeTaskSubTypeIntent; if (subType == null) return; const latestStore = syncCurrentDraftBucketToStore(); applyDraftBucketToComposer(subType, latestStore); setActiveTaskSubType(subType); useActiveTaskSubTypeStore.getState().clearPendingActiveTaskSubType(); setIsComposerCollapsed(false); }, [activeTaskSubTypeIntent, applyDraftBucketToComposer, syncCurrentDraftBucketToStore]); /** 消费跨页 applyMedia intent:只订阅 seq,避免 composer 状态变化导致 effect 连环触发 */ useEffect(() => { if (applyMediaIntentSeq === 0) return; const intent = useGlobalPromptImageStore.getState().intent; if (!intent || intent.seq !== applyMediaIntentSeq) return; if (applyMediaIntentProcessingSeqRef.current === applyMediaIntentSeq) return; applyMediaIntentProcessingSeqRef.current = applyMediaIntentSeq; void applyExternalMediaIntent(intent).finally(() => { if (applyMediaIntentProcessingSeqRef.current === applyMediaIntentSeq) { applyMediaIntentProcessingSeqRef.current = 0; } useGlobalPromptImageStore.getState().clearPendingMedia(); }); }, [applyMediaIntentSeq, applyExternalMediaIntent]); /** 跨页「使用模板」:消费 store → 合成 snapshot → 切 999 → 立刻 clear(避免 Tab 被锁在 999)。 */ useEffect(() => { if (templateSelectIntentSeq === 0) return; const intent = useTemplateSelectStore.getState().intent; if (!intent || intent.seq !== templateSelectIntentSeq) return; const { templateId, images } = intent; if (templateId == null || templateId === "") return; hasExternalInitIntentRef.current = true; syncCurrentDraftBucketToStore(); const bucket = draftStoreRef.current.draftsBySubType["999"] ?? {}; activateTemplateComposer(buildTemplateComposerSnapshotFromBucket(bucket, templateId, images)); useTemplateSelectStore.getState().clearPendingTemplateSelect(); }, [activateTemplateComposer, syncCurrentDraftBucketToStore, templateSelectIntentSeq]); /** 模板类作品制作同款:拉 taskInfo → 合成 snapshot → 切 999(与「使用模板」同源,不用草稿里的旧 templateId)。 */ useEffect(() => { if (makeSameTemplateIntentSeq === 0) return; const intent = useMakeSameStore.getState().intent; if (!intent || intent.mode !== "template" || intent.seq !== makeSameTemplateIntentSeq) return; const { postId, autoCreateAfterFill } = intent; if (postId == null) return; hasExternalInitIntentRef.current = true; let cancelled = false; void (async () => { try { const res: any = await getAssetDetail(postId); if (cancelled) return; const taskInfo = res?.data?.taskInfo as Record | undefined; syncCurrentDraftBucketToStore(); const bucket = draftStoreRef.current.draftsBySubType["999"] ?? {}; const snapshot = buildTemplateComposerSnapshotFromTaskInfo(bucket, taskInfo, { autoCreateAfterFill, }); if (!snapshot) return; activateTemplateComposer(snapshot); } finally { if (!cancelled) useMakeSameStore.getState().clearPendingMakeSame(); } })(); return () => { cancelled = true; }; }, [activateTemplateComposer, makeSameTemplateIntentSeq, syncCurrentDraftBucketToStore]); const flushPendingCharacterToReference = useCallback(() => { if (activeTaskSubType !== 203) return; const payload = pendingReferenceCharacterRef.current; if (!payload) return; const handler = applyCharacterToReferenceHandlerRef.current; if (!handler) return; handler(payload); pendingReferenceCharacterRef.current = null; }, [activeTaskSubType]); // 应用角色到参考 useEffect(() => { if (!applyCharacterIntent) return; hasExternalInitIntentRef.current = true; pendingReferenceCharacterRef.current = applyCharacterIntent.payload; const latestStore = syncCurrentDraftBucketToStore(); applyDraftBucketToComposer(203, latestStore); setActiveTaskSubType(203); setIsComposerCollapsed(false); flushPendingCharacterToReference(); useCharacterToReferenceStore.getState().clearPendingCharacter(); }, [applyCharacterIntent, applyDraftBucketToComposer, flushPendingCharacterToReference, syncCurrentDraftBucketToStore]); useEffect(() => { flushPendingCharacterToReference(); }, [flushPendingCharacterToReference]); // ==================== 参数与费用:字段、预设、尺寸预览、价格 ==================== /** 当前模型可用参数字段 */ const modelParamFields = useMemo(() => getComposerParamFieldsForModel(activeModelItem), [activeModelItem]); /** 参数预设按钮:文案片段 + 竖条分隔 */ const paramSummary = useMemo(() => { const items = modelParamFields .map((field) => { const selectedValue = selectedParamValues[field.key]; const option = field.options.find((item) => item.value === selectedValue) ?? field.options[0]; const label = option?.label ?? String(option?.value ?? ""); return { key: field.key, label }; }) .filter((item) => Boolean(item.label)); if (items.length === 0) return t("composer.paramPreset"); return ( <> {items.map((item, index) => ( {index > 0 ? ( ))} ); }, [modelParamFields, selectedParamValues, t]); /** 当前比例和分辨率对应尺寸预览 */ const ratioSizePreview = useMemo(() => { const ratioField = modelParamFields.find((field) => isRatioField(field.key, field.name)); if (!ratioField) return null; const selectedValue = selectedParamValues[ratioField.key]; const fallbackValue = ratioField.options[0]?.value; const activeRatio = String(selectedValue ?? fallbackValue ?? ""); if (!activeRatio) return null; // 找到分辨率字段的值,支持动态清晰度 const resolutionField = modelParamFields.find((field) => field.key.toLowerCase().includes("resolution") || field.name?.toLowerCase().includes("resolution") ); const resolutionValue = resolutionField ? selectedParamValues[resolutionField.key] : undefined; return getResolutionSizePreview(activeRatio, resolutionValue); }, [modelParamFields, selectedParamValues]); /** 已上传视频总时长(秒)。无可用时长时回退为 0。 */ const inputVideoDurationSeconds = useMemo(() => { const total = mediaItems.reduce((sum, item) => { if (item.type !== "video") return sum; const duration = Number(item.duration); if (!Number.isFinite(duration) || duration <= 0) return sum; return sum + duration; }, 0); if (!Number.isFinite(total) || total <= 0) return 0; return Math.round(total); }, [mediaItems]); /** 生成消耗:统一由 useGenerateCost 根据计费策略计算。 */ const generateCost = useGenerateCost({ activeModelItem, selectedParamValues, activeTaskSubType, inputVideoDurationSeconds, isActionMimicTask, selectedActionMimicItem, }); /** 切换模型或字段变化时,保留仍合法的已选参数,其余走默认偏好。 */ useEffect(() => { if (modelParamFields.length === 0) return; setSelectedParamValues((prev) => buildPreferredParamValues(modelParamFields, prev)); }, [activeModelItem, modelParamFields]); // ==================== 提交流程:校验、可选扩写、发起生成、跳转刷新 ==================== /** 点击创建任务 */ const handleCreateGenTaskClick = useCallback(async () => { if (createGenTaskInFlightRef.current) return; createGenTaskInFlightRef.current = true; setIsCreatingGenTask(true); try { if (chatPrompt.trim() === "" && !isActionMimicTask) { Message.error(t("composer.promptRequired")); return; } const subTypeCfg = getTaskSubTypeRow(activeTaskSubType); const minRequired = subTypeCfg?.minUploadImages ?? 0; if (minRequired > 0 && mediaItems.length < minRequired) { const label = subTypeCfg?.taskNameKey ? t(subTypeCfg.taskNameKey) : t("composer.taskName.reference"); Message.error(t("composer.minUploadRequired", { label, min: minRequired })); return; } const { uploadImages, uploadVideos, uploadVoice, hasAudio, hasVideo } = splitUploadMediaItems(mediaItems, modelUploadImageLimit); const referenceSubjects = referenceStructure.referenceSubjectList; const hasReferenceImage = isReferenceTask && referenceSubjects.some((item) => item.type === "image"); const hasReferenceVideo = isReferenceTask && referenceSubjects.some((item) => item.type === "video"); const hasImage = uploadImages.length > 0 || hasReferenceImage; const hasCompanionVideo = hasVideo || hasReferenceVideo || (isActionMimicTask && Boolean(selectedActionMimicItem?.url)); if (hasAudio && !hasImage && !hasCompanionVideo) { Message.error(t("composer.audioRequiresImageOrVideo")); return; } if (isActionMimicTask && !selectedActionMimicItem) { Message.error(t("composer.actionMimicExampleRequired", { defaultValue: "请先选择一个动作模仿示例" })); return; } if (!(await ensureSufficientPoints(generateCost))) return; const videos = isActionMimicTask ? (selectedActionMimicItem?.url ? [selectedActionMimicItem.url] : []) : uploadVideos; const promptForTask = chatPrompt.trim(); const payload: Record = { subType: activeTaskSubType, ...buildGenTaskModelPayload(activeModelItem), type: getTaskSubTypeRow(activeTaskSubType)?.type, ...selectedParamValues, ...dimensionPayloadFromPreview(selectedParamValues.resolution, ratioSizePreview), chatPrompt: promptForTask, images: uploadImages, videos, voice: uploadVoice, }; if (isActionMimicTask) { payload.templateVideoMaterialId = selectedActionMimicItem?.type === "官方示例" ? selectedActionMimicItem.id : undefined; if (payload.templateVideoMaterialId == null) delete payload.templateVideoMaterialId; } if (isReferenceTask) { payload.referenceSubjectList = referenceStructure.referenceSubjectList; } if (uploadImages.length <= 0) delete payload.images; if (videos.length <= 0) delete payload.videos; if (uploadVoice.length <= 0) delete payload.voice; try { Message.success(t("composer.taskCreating")); await createGenTask(payload); closeAssetDetailsModal(); void setUser(); const taskType = getTaskSubTypeRow(activeTaskSubType)?.type ?? 1; const onMyCreations = currentPathname === ROUTES.CREATE_MY_CREATIONS; const currentType = Number(new URLSearchParams(currentSearch).get("type")); const isSameTypeOnMyCreations = onMyCreations && Number.isFinite(currentType) && currentType === taskType; // 已在「我的创造」且 type 一致时,发事件让列表静默刷新,避免整页重拉。 if (isSameTypeOnMyCreations) { window.dispatchEvent(new CustomEvent("myCreations:taskCreated", { detail: { type: taskType } })); } else { const nextSearch = new URLSearchParams(); nextSearch.set("type", String(taskType)); nextSearch.set(MY_CREATIONS_REFRESH_QUERY, String(Date.now())); navigateToMyCreations( { pathname: ROUTES.CREATE_MY_CREATIONS, search: `?${nextSearch.toString()}` }, // 已在「我的创造」时用 replace,避免历史记录被同一页面刷屏。 { replace: onMyCreations }, ); } } catch { /* 失败时 request 拦截器已提示 */ } } finally { createGenTaskInFlightRef.current = false; setIsCreatingGenTask(false); } }, [ navigateToMyCreations, currentPathname, currentSearch, activeTaskSubType, activeModelItem, selectedParamValues, mediaItems, modelUploadImageLimit, chatPrompt, ratioSizePreview, referenceStructure, isActionMimicTask, selectedActionMimicItem, generateCost, t, ]); useEffect(() => { if (!pendingAutoCreateAfterMakeSameRef.current) return; if (createGenTaskInFlightRef.current) return; if (activeTaskSubType == null || activeModelItem == null) return; if (activeTaskSubType === 205 && !selectedActionMimicItem) return; pendingAutoCreateAfterMakeSameRef.current = false; void handleCreateGenTaskClick(); }, [activeModelItem, activeTaskSubType, handleCreateGenTaskClick, selectedActionMimicItem]); // ==================== 草稿持久化:回填、模板同步、防抖保存 ==================== /** 切换模型后,超出 `uploadImageLimit` 的图片从列表中移除。 */ useEffect(() => { if (modelUploadImageLimit == null) return; setMediaItems((prev) => { let imageCount = 0; const next = prev.filter((item) => { if (item.type !== "image") return true; if (imageCount >= modelUploadImageLimit) return false; imageCount += 1; return true; }); return next.length === prev.length ? prev : next; }); }, [activeModelItem?.id, modelUploadImageLimit]); // 页面进入时回填用户上次编辑草稿 useEffect(() => { let cancelled = false; void getUserOperationDetail({}) .then((res: any) => { if (cancelled) return; const dataRaw = res?.data?.data ?? ""; const store = parseDraftStore(typeof dataRaw === "string" ? dataRaw : String(dataRaw ?? "")) ?? { version: 2 as const, currentProductType: null, draftsBySubType: {}, }; draftStoreRef.current = store; latestSavedDraftStoreRef.current = JSON.stringify(store); if (hasExternalInitIntentRef.current) return; applyDraftBucketToComposer(store.currentProductType, store); if (store.currentProductType != null) { setActiveTaskSubType(store.currentProductType); } }) .catch(() => { }) .finally(() => { hasLoadedUserOperationRef.current = true; }); return () => { cancelled = true; }; }, [applyDraftBucketToComposer]); // 只有草稿发生改变才保存,并做防抖 useEffect(() => { if (!hasLoadedUserOperationRef.current) return; const nextStore = syncCurrentDraftBucketToStore(); const snapshot = JSON.stringify(nextStore); if (snapshot === latestSavedDraftStoreRef.current) return; const timer = window.setTimeout(() => { const freshStore = syncCurrentDraftBucketToStoreRef.current(); const nextSnapshot = JSON.stringify(freshStore); if (nextSnapshot === latestSavedDraftStoreRef.current) return; void saveUserOperation({ data: nextSnapshot, }) .then(() => { latestSavedDraftStoreRef.current = nextSnapshot; }) .catch(() => { /* 保存失败时忽略,后续变更会继续尝试 */ }); }, GLOBAL_PROMPT_SAVE_DEBOUNCE_MS); return () => { window.clearTimeout(timer); }; }, [syncCurrentDraftBucketToStore, templateDraftSnapshot]); const handleTaskSubTypeChange = useCallback((nextTaskSubType: number) => { const currentTemplateSnapshot = isTemplateTask ? (templateComposerRef.current?.getSnapshot() ?? templateDraftRef.current) : undefined; if (currentTemplateSnapshot) { setTemplateDraftSnapshot(currentTemplateSnapshot); } const latestStore = syncCurrentDraftBucketToStore({ templateSnapshot: currentTemplateSnapshot }); applyDraftBucketToComposer(nextTaskSubType, latestStore); setActiveTaskSubType(nextTaskSubType); if (nextTaskSubType !== 203) { pendingReferenceCharacterRef.current = null; } }, [applyDraftBucketToComposer, isTemplateTask, syncCurrentDraftBucketToStore]); const handleApplyCharacterToReferenceHandlerRegister = useCallback( ((handler: ((payload: { id: number | string; title: string; threeViewImage: string }) => void) | null) => { applyCharacterToReferenceHandlerRef.current = handler; flushPendingCharacterToReference(); }), [flushPendingCharacterToReference], ); const isEmbedded = mode === "embedded"; // ==================== 视图结构:头部标签、主体输入、底部参数与操作 ==================== return ( // 全局创作输入区容器
{/* 顶部区域:模式标签 + 头部操作 */}
{/* 模式标签组 */}
{aiCategories.map((item, idx) => { const isActive = activeTaskSubType === item.taskSubType; return ( ); })}
{/* 头部操作按钮组 */}
{t("pages.assetDetails.ai_generated")}
{isTemplateTask ? ( ) : ( <> {/* 中间区域:上传入口与提示 */}
{ if (!isComposerCollapsed) return; setIsComposerCollapsed(false); evt.preventDefault(); evt.stopPropagation(); }} onDragOver={(evt) => { evt.preventDefault(); evt.stopPropagation(); }} onDragLeave={(evt) => { evt.preventDefault(); evt.stopPropagation(); }} onDrop={handleUploadAreaDrop} > {isHeadTailTask ? ( ) : isReferenceTask ? ( ) : isActionMimicTask ? ( ) : ( )}
{/* 底部区域:参数与主操作 */}
{/* 参数与模型选择 */}
{/* 参数预设下拉 */} {modelParamFields.map((field) => { const selectedValue = selectedParamValues[field.key]; const fallbackValue = field.options[0]?.value; const activeValue = selectedValue ?? fallbackValue; const ratioField = isRatioField(field.key, field.name); return (

{field.key === "batchSize" ? t("composer.fields.batchSize") : field.name}

{field.options.map((option, idx) => { const optionValue = option.value; const optionLabel = option.label ?? String(optionValue); const isActive = activeValue === optionValue; const ratioPreview = ratioField ? ratioChipPreviewSize(optionLabel) : null; return ( ); })}
{ratioField && ratioSizePreview ? (

{t("composer.fields.size")}

W {ratioSizePreview.width} H {ratioSizePreview.height}
) : null}
); })}
} > {/* 模型选择下拉 */}

{t("composer.modelLibrary")}

{aiModels.map((item, idx) => { const isActive = activeModelItem === item; return ( ); })}
} >
{/* 重置与消耗展示 */}
{/* */} {/* */}
)}
); }