全新popiart网站 react项目重构
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1832 lines
93 KiB

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<string, string | number>;
activeTaskSubType: number | null;
inputVideoDurationSeconds: number;
isActionMimicTask: boolean;
selectedActionMimicItem: ActionMimicSelectedItem | null;
}) {
const { activeModelItem, selectedParamValues, activeTaskSubType, inputVideoDurationSeconds, isActionMimicTask, selectedActionMimicItem } = params;
const [generateCost, setGenerateCost] = useState<number>(0);
const debounceTimerRef = useRef<number | null>(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<AiCategoryItem[]>([]);
const [activeTaskSubType, setActiveTaskSubType] = useState<number | null>(null); // 当前产品分类 ID
const [aiModels, setAiModels] = useState<AiModelItem[]>([]); // AI 模型列表
const [activeModelItem, setActiveModelItem] = useState<AiModelItem | null>(null); // 当前选中模型
const [isComposerCollapsed, setIsComposerCollapsed] = useState(false); // 底部面板折叠状态
const composerTabsWheelRef = useHorizontalWheelScroll<HTMLDivElement>({ enabled: !isComposerCollapsed });
const composerBodyWheelRef = useHorizontalWheelScroll<HTMLDivElement>({
enabled: true,
ignoreWithin: "textarea, .arco-textarea, .referenceUploadPromptSection__promptEditor, .referenceUploadPromptSection__promptEditorWrap",
});
const [modelMenuOpen, setModelMenuOpen] = useState(false); // 模型下拉开关
const [paramMenuOpen, setParamMenuOpen] = useState(false); // 参数下拉开关
const [selectedParamValues, setSelectedParamValues] = useState<Record<string, string | number>>({}); // 已选参数值
const [chatPrompt, setChatPrompt] = useState(""); // 输入框内容
const activeTaskSubTypeRef = useRef<number | null>(activeTaskSubType);
activeTaskSubTypeRef.current = activeTaskSubType;
const recordPromptDepointTimerRef = useRef<number | null>(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<ComposerMediaItem[]>([]); // 已上传媒体列表
const [selectedActionMimicItem, setSelectedActionMimicItem] = useState<ActionMimicSelectedItem | null>(null);
const [templateVideoMaterialId, setTemplateVideoMaterialId] = useState<number | string | null>(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<number | null>(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<Map<number, AiModelItem[]>>(new Map());
const templateComposerRef = useRef<GlobalTemplateComposerShellHandle | null>(null);
const [templateDraftSnapshot, setTemplateDraftSnapshot] = useState<GlobalTemplateComposerSnapshot>({
selectedTemplateId: null,
images: [],
selectedParamValues: { ...TEMPLATE_COMPOSER_DEFAULT_PARAMS },
});
const templateDraftRef = useRef<GlobalTemplateComposerSnapshot>(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<GlobalPromptDraftStoreV2>({
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<string, string | number>;
} | 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<GlobalPromptDraftBucket, "selectedAiModelId" | "selectedParamValues"> = {
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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<string, unknown>;
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<AiModelItem[]> => {
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<string, unknown>);
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<string, unknown> | 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<string, unknown> | 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<ComposerParamField[]>(() => 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) => (
<Fragment key={item.key}>
{index > 0 ? (
<span className="layoutShell__composerParamPresetDivider" aria-hidden="true" />
) : null}
{item.label}
</Fragment>
))}
</>
);
}, [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<string, unknown> = {
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 (
// 全局创作输入区容器
<section
className={classNames(
"layoutShell__composer",
isComposerCollapsed && "layoutShell__composer_collapsed",
isEmbedded && "layoutShell__composer_embedded",
isEmbedded && embeddedVisible && "layoutShell__composer_embeddedVisible",
)}
aria-label={t("composer.aria.userInputPanel")}
>
{/* 顶部区域:模式标签 + 头部操作 */}
<div className="layoutShell__composerChrome layoutShell__composerChrome--header">
<div className="layoutShell__composerHeader">
{/* 模式标签组 */}
<div
ref={composerTabsWheelRef}
className="layoutShell__composerTabs"
role="tablist"
aria-label={t("composer.aria.modeTabs")}
>
{aiCategories.map((item, idx) => {
const isActive = activeTaskSubType === item.taskSubType;
return (
<button
key={`${String(item.taskSubType ?? item.id ?? "category")}-${idx}`}
type="button"
className={classNames("layoutShell__composerTab", isActive && "layoutShell__composerTab_active")}
role="tab"
aria-selected={isActive}
data-task-sub-type={item.taskSubType}
onClick={() => handleTaskSubTypeChange(item.taskSubType)}
>
{typeof item.icon === "string" && item.icon ? (
<img
src={
/^https?:\/\//i.test(item.icon) || item.icon.startsWith("data:")
? item.icon
: new URL(item.icon, import.meta.url).href
}
/>
) : null}
<span>{item.taskSubType === 999 ? t("composer.tabs.template") : item.name}</span>
</button>
);
})}
</div>
{/* 头部操作按钮组 */}
<div className="layoutShell__composerHeaderActions">
<div className="layoutShell__composerHeaderActions__aiGenerated">
{t("pages.assetDetails.ai_generated")}
</div>
<Tooltip content={t("layout.usageTutorial")}>
<button
type="button"
className="layoutShell__composerIconBtn"
onClick={() => {
if (usageTutorialPageUrl) window.open(usageTutorialPageUrl, "_blank");
}}
>
<img style={{ transform: "none" }} src={COMPOSER_ASSET.icHelp} />
</button>
</Tooltip>
<Tooltip content={t("composer.actions.collapsePanel")}>
<button
type="button"
className="layoutShell__composerIconBtn"
aria-label={t("composer.actions.collapsePanel")}
aria-expanded={!isComposerCollapsed}
onClick={requestComposerCollapse}
>
<img src={COMPOSER_ASSET.icArrowRightGray} />
</button>
</Tooltip>
</div>
</div>
</div>
{isTemplateTask ? (
<GlobalTemplateComposerShell
ref={templateComposerRef}
isComposerCollapsed={isComposerCollapsed}
onExpandFromCollapsedBody={handleComposerChromeExpand}
initialSnapshot={templateDraftSnapshot}
onAppendImagesHandlerRegister={handleTemplateApplyMediaHandlerRegister}
onSnapshotChange={setTemplateDraftSnapshot}
pathname={currentPathname}
navigateTo={navigateToMyCreations}
/>
) : (
<>
{/* 中间区域:上传入口与提示 */}
<div
ref={composerBodyWheelRef}
className="layoutShell__composerBody"
onClickCapture={(evt) => {
if (!isComposerCollapsed) return;
setIsComposerCollapsed(false);
evt.preventDefault();
evt.stopPropagation();
}}
onDragOver={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
onDragLeave={(evt) => {
evt.preventDefault();
evt.stopPropagation();
}}
onDrop={handleUploadAreaDrop}
>
{isHeadTailTask ? (
<HeadTailUploadPromptSection
mediaItems={mediaItems}
uploadAccept={uploadAccept}
pendingHeadTailSlotRef={pendingHeadTailSlotRef}
chatPrompt={chatPrompt}
onRemoveMedia={handleRemoveMedia}
onUploadInputChange={handleUploadInputChange}
onChatPromptChange={handleChatPromptChange}
/>
) : isReferenceTask ? (
<ReferenceUploadPromptSection
visibleMediaItems={mediaItems}
modelUploadImageLimit={modelUploadImageLimit}
allowNonImageUpload={allowNonImageUpload}
composerMediaUploadEnabled={composerMediaUploadEnabled}
uploadAccept={uploadAccept}
chatPrompt={chatPrompt}
onRemoveMedia={handleRemoveMedia}
onRestoreSubjectImages={handleRestoreSubjectImages}
onReferenceStructureChange={setReferenceStructure}
onApplyCharacterToReferenceHandlerRegister={handleApplyCharacterToReferenceHandlerRegister}
onUploadInputChange={handleUploadInputChange}
onChatPromptChange={handleChatPromptChange}
/>
) : isActionMimicTask ? (
<ActionMimicUploadPromptSection
visibleMediaItems={mediaItems}
modelUploadImageLimit={modelUploadImageLimit}
allowNonImageUpload={allowNonImageUpload}
composerMediaUploadEnabled={composerMediaUploadEnabled}
uploadAccept={uploadAccept}
chatPrompt={chatPrompt}
placeholder={taskSubTypePlaceholder}
onRemoveMedia={handleRemoveMedia}
onUploadInputChange={handleUploadInputChange}
onChatPromptChange={handleChatPromptChange}
onSelectedExampleItemChange={setSelectedActionMimicItem}
templateVideoMaterialId={templateVideoMaterialId}
/>
) : (
<CommonUploadPromptSection
visibleMediaItems={mediaItems}
modelUploadImageLimit={modelUploadImageLimit}
allowNonImageUpload={allowNonImageUpload}
composerMediaUploadEnabled={composerMediaUploadEnabled}
uploadAccept={uploadAccept}
chatPrompt={chatPrompt}
placeholder={taskSubTypePlaceholder}
onRemoveMedia={handleRemoveMedia}
onUploadInputChange={handleUploadInputChange}
onChatPromptChange={handleChatPromptChange}
/>
)}
</div>
{/* 底部区域:参数与主操作 */}
<div className="layoutShell__composerChrome layoutShell__composerChrome--footer">
<div className="layoutShell__composerFooter">
{/* 参数与模型选择 */}
<div className="layoutShell__composerChips" >
{/* 参数预设下拉 */}
<Dropdown
trigger="click"
position="tl"
popupVisible={paramMenuOpen}
onVisibleChange={setParamMenuOpen}
getPopupContainer={getDropdownPopupContainer}
triggerProps={{ className: "layoutShell__composerParamDropdownTrigger" }}
droplist={
<div className="layoutShell__composerParamDropdown" role="menu" aria-label={t("composer.aria.paramPresetList")}>
{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 (
<div key={field.key} className="layoutShell__composerParamGroup">
<p className="layoutShell__composerParamTitle">{field.key === "batchSize" ? t("composer.fields.batchSize") : field.name}</p>
<div
className={classNames(
"layoutShell__composerParamOptions",
ratioField && "layoutShell__composerParamOptions_ratio",
)}
role="group"
aria-label={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 (
<button
key={`${field.key}-${String(optionValue)}-${idx}`}
type="button"
role="menuitemradio"
aria-checked={isActive}
className={classNames(
"layoutShell__composerParamOption",
isActive && "layoutShell__composerParamOption_active",
ratioField && "layoutShell__composerParamOption_ratio",
)}
onClick={() => {
setSelectedParamValues((prev) => ({ ...prev, [field.key]: optionValue }));
}}
>
{ratioField ? (
<>
<span className="layoutShell__composerRatioShapeSlot" aria-hidden="true">
<span
className="layoutShell__composerRatioShape"
style={{
width: `${ratioPreview?.width ?? 20}px`,
height: `${ratioPreview?.height ?? 20}px`,
}}
/>
</span>
<span className="layoutShell__composerRatioLabel">{optionLabel}</span>
</>
) : (
optionLabel
)}
</button>
);
})}
</div>
{ratioField && ratioSizePreview ? (
<div className="layoutShell__composerParamRatioSizeWrap" aria-label={t("composer.aria.sizePreview")}>
<p className="layoutShell__composerParamTitle">{t("composer.fields.size")}</p>
<div className="layoutShell__composerParamRatioSizeRow">
<span className="layoutShell__composerParamRatioSizeValue">W {ratioSizePreview.width}</span>
<img
className="layoutShell__composerParamRatioSizeLink"
src={COMPOSER_ASSET.icLink}
/>
<span className="layoutShell__composerParamRatioSizeValue">H {ratioSizePreview.height}</span>
</div>
</div>
) : null}
</div>
);
})}
</div>
}
>
<button type="button" className="layoutShell__composerParamPreset" aria-label={t("composer.paramPreset")}>
<span className="layoutShell__composerParamPresetItem">{paramSummary}</span>
</button>
</Dropdown>
{/* 模型选择下拉 */}
<Dropdown
trigger="click"
position="tl"
popupVisible={modelMenuOpen}
onVisibleChange={setModelMenuOpen}
getPopupContainer={getDropdownPopupContainer}
triggerProps={{ className: "layoutShell__composerModelDropdownTrigger" }}
droplist={
<div className="layoutShell__composerModelDropdown" role="menu" aria-label={t("composer.modelLibrary")}>
<p className="layoutShell__composerModelDropdownTitle">{t("composer.modelLibrary")}</p>
<div className="layoutShell__composerModelDropdownList">
{aiModels.map((item, idx) => {
const isActive = activeModelItem === item;
return (
<button
key={item.id ?? idx}
type="button"
role="menuitem"
className={classNames(
"layoutShell__composerModelDropdownItem",
isActive && "layoutShell__composerModelDropdownItem_active",
)}
onClick={() => {
setActiveModelItem(item);
setModelMenuOpen(false);
setSelectedParamValues(
buildPreferredParamValues(getComposerParamFieldsForModel(item), {}),
);
}}
>
<img
className="layoutShell__composerModelDropdownItemIcon"
src={
typeof item.icon === "string" &&
item.icon &&
(/^https?:\/\//i.test(item.icon) || item.icon.startsWith("data:"))
? item.icon
: typeof item.icon === "string" && item.icon
? new URL(item.icon, import.meta.url).href
: COMPOSER_ASSET.icModel
}
/>
<span className="layoutShell__composerModelDropdownItemText">
<span className="layoutShell__composerModelDropdownItemName">{item.name}</span>
<span className="layoutShell__composerModelDropdownItemDesc">{item.description}</span>
</span>
</button>
);
})}
</div>
</div>
}
>
<button
type="button"
className="layoutShell__composerModelChip"
aria-haspopup="menu"
aria-expanded={modelMenuOpen}
aria-label={t("composer.actions.selectModel")}
>
<img src={COMPOSER_ASSET.icModel} />
<span>{activeModelItem?.name ?? ""}</span>
</button>
</Dropdown>
</div>
{/* 重置与消耗展示 */}
<div className="layoutShell__composerActions">
{/* <Tooltip content={t("composer.actions.reset")}> */}
<button
type="button"
className="layoutShell__composerResetBtn"
aria-label={t("composer.actions.reset")}
onClick={handleResetComposer}
>
<img src={COMPOSER_ASSET.icRefresh} alt="" />
<span>{t("composer.actions.reset")}</span>
</button>
{/* </Tooltip> */}
<button
type="button"
className="layoutShell__composerCostBtn"
disabled={isCreatingGenTask}
aria-busy={isCreatingGenTask}
onClick={handleCreateGenTaskClick}
>
<img src={COMPOSER_ASSET.icStarsWhite} />
<span>{generateCost}</span>
</button>
</div>
</div>
</div>
</>
)}
</section>
);
}