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
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>
|
|
);
|
|
}
|
|
|