大量优化 模型改成gpt6
Deploy dev / deploy (push) Successful in 41s

This commit is contained in:
Azmat@qq.com
2026-09-28 18:15:08 +08:00
parent a84431c9c5
commit 4421706290
14 changed files with 1878 additions and 540 deletions
+343 -220
View File
@@ -20,14 +20,13 @@ import {
Plus,
RefreshCw,
Search,
SlidersHorizontal,
Sparkles,
Trash2,
Users,
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import type { AITask, Asset, CoverProductInfo, CreationRef, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
import { pointsPerImageFromCatalog } from "../components/free-create/constants";
import { api } from "../api";
import { useFileDrop } from "../components/use-file-drop";
@@ -35,6 +34,7 @@ import { imageModelPickerOptions } from "../model-display";
import { ModelLibrary } from "../components/model-library";
import { SkeletonRows, SystemLoading } from "../components/loading";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { RichMentionEditor, type RichMentionEditorHandle } from "../components/rich-mention-editor";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
@@ -454,11 +454,11 @@ const MODE_META: Record<
},
cover: {
title: "平台套图",
desc: "根据平台规范生成主图、卖点图、细节图与场景图",
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
desc: "根据商品信息和商品图生成主图、卖点图、细节图与场景图",
// 通用电商主图规范:默认 1:1(不再按平台切换比例)
ratio: "1:1",
// YYX#4:只出平台头图,去掉「详情排版」
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
// 仅作兜底文案;实际提交的 prompt 由「商品信息」拼出(composeCoverPrompt)
promptTemplate: (title) => `${title},电商主图套图,统一视觉,商品主体清晰`
}
};
@@ -479,38 +479,73 @@ const IMAGE_SUGGESTIONS = [
{ label: "都市夜景海报", prompt: "电影感都市夜景,街道湿润反射霓虹,4K 高清产品海报" }
];
/* 平台套图 · 平台卡(YYX#row10:用真平台 logo,素材在 public/assets/svg;
logo 留作图加载失败时的兜底字符,img 为真 logo) */
const PLATFORM_OPTIONS = [
{ id: "dy", name: "抖音电商", logo: "抖", img: "/assets/svg/icon-platform-douyin.svg" },
{ id: "tb", name: "淘宝", logo: "淘", img: "/assets/svg/icon-platform-taobao.svg" },
{ id: "tm", name: "天猫", logo: "猫", img: "/assets/svg/icon-platform-tmall.svg" },
{ id: "jd", name: "京东", logo: "京", img: "/assets/svg/icon-platform-jd.svg" },
{ id: "pdd", name: "拼多多", logo: "拼", img: "/assets/svg/icon-platform-pdd.svg" },
{ id: "xhs", name: "小红书", logo: "红", img: "/assets/svg/icon-platform-xiaohongshu.svg" },
{ id: "ks", name: "快手", logo: "快", img: "/assets/svg/icon-platform-kuaishou.svg" },
{ id: "sph", name: "视频号", logo: "视", img: "/assets/svg/icon-platform-wechat-video.svg" },
{ id: "amz", name: "亚马逊", logo: "a", img: "/assets/svg/icon-platform-amazon.svg" },
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
];
/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */
/* 前端平台 id(dy/tb…) ↔ 后端规范化 platform_id。平台套图已不再选平台(走通用电商主图规范),
这里只保留给旧记录:恢复旧批次时还原平台 key,重跑旧批次时原样带回 platform_id(后端仍兼容)。 */
const PLATFORM_ID_MAP: Record<string, string> = {
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
};
/* YYX#row10:平台 logo —— 优先真 logo 图(public/assets/svg),加载失败回退到品牌色块+字符。
className 默认 p-logo(平台卡/筛选弹窗),分组头传 cg-logo。 */
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
const [err, setErr] = useState(false);
return (
<span className={`${className || "p-logo"} p-logo-${p.id}${p.img && !err ? " has-img" : ""}`}>
{p.img && !err
? <img className="p-logo-img" src={p.img} alt={p.name} loading="lazy" decoding="async" onError={() => setErr(true)} />
: p.logo}
</span>
);
/* 平台套图 · 商品信息(替代原「画面要求」):用户填写卖点 / 作用 / 人群 / 规格 / 补充,
默认从商品资料(selling_points / description / target_audience / specs)带入。 */
type CoverInfoDraft = { sellingPoints: string; effect: string; audience: string; specs: string; notes: string };
function formatProductSpecs(specs?: Record<string, unknown>): string {
if (!specs || typeof specs !== "object") return "";
return Object.entries(specs)
.filter(([, value]) => typeof value === "string" || typeof value === "number")
.map(([key, value]) => `${key}:${String(value).trim()}`)
.filter((line) => !line.endsWith(":"))
.slice(0, 6)
.join(";");
}
function coverInfoFromProduct(p?: Product): CoverInfoDraft {
const points = [...(p?.selling_points || [])]
.sort((a, b) => a.sort_order - b.sort_order)
.map((sp) => {
const title = (sp.title || "").trim();
const detail = (sp.detail || "").trim();
if (!title) return detail.slice(0, 40);
return detail && detail.length <= 40 ? `${title}:${detail}` : title;
})
.filter(Boolean);
return {
sellingPoints: points.join("\n"),
effect: (p?.description || "").trim().slice(0, 300),
audience: (p?.target_audience || "").trim(),
specs: formatProductSpecs(p?.specs),
notes: ""
};
}
/* 与后端 _split_selling_points 同口径:按换行 / 分号拆,只有一行时再按顿号 / 逗号拆,最多 6 条 */
function splitSellingPoints(text: string): string[] {
const lines = text.split(/[\n\r;;]+/).map((x) => x.replace(/^\s*(?:[-•·*]+|\d+\s*[.、))])\s*/, "").trim()).filter(Boolean);
const parts = lines.length <= 1 ? (lines[0] || text).split(/[、,,]+/).map((x) => x.trim()).filter(Boolean) : lines;
return parts.slice(0, 6);
}
function coverInfoPayload(d: CoverInfoDraft): CoverProductInfo {
const out: CoverProductInfo = {};
if (d.sellingPoints.trim()) out.selling_points = d.sellingPoints.trim();
if (d.effect.trim()) out.effect = d.effect.trim();
if (d.audience.trim()) out.audience = d.audience.trim();
if (d.specs.trim()) out.specs = d.specs.trim();
if (d.notes.trim()) out.notes = d.notes.trim();
return out;
}
/* 可读摘要:作为批次 prompt(批次头展示 / 搜索 / 旧客户端兼容),结构化信息另走 product_info */
function composeCoverPrompt(title: string, d: CoverInfoDraft): string {
const parts: string[] = [];
const points = splitSellingPoints(d.sellingPoints);
if (points.length) parts.push(`核心卖点:${points.join("、")}`);
if (d.effect.trim()) parts.push(`商品作用:${d.effect.trim()}`);
if (d.audience.trim()) parts.push(`适用人群:${d.audience.trim()}`);
if (d.specs.trim()) parts.push(`规格:${d.specs.trim()}`);
if (d.notes.trim()) parts.push(`补充:${d.notes.trim()}`);
return `${title}电商主图 · ${parts.join(";")}`;
}
/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态;支持多批并行跑。
@@ -530,10 +565,14 @@ type GenBatch = {
modelId?: string;
/** 该批次选中的模特展示名(导航头显示) */
modelName?: string;
/** 该批次选中的平台 id 列表(平台套图:多选 → 各平台分组,P0③) */
/** 旧平台套图记录的平台 id 列表(已不再选平台;仅用于重跑旧批次时原样带回) */
platformIds?: string[];
/** 平台套图:本批提交的商品信息(重跑 / 补图沿用) */
productInfo?: CoverProductInfo;
/** 该批次提交的参考图(图片创作:用户上传作生成参考):批次头回显 + 重跑时凭 assetId 原样复用 */
refs?: { name: string; url: string; assetId?: string }[];
/** 图片创作 @ 引用的结构化实体;重跑时仍按实体取事实和参考图。 */
mentions?: CreationRef[];
/** 后端批次 id:重跑/补图带它回去,新任务归回原批次(否则后端裂成新批次,刷新后多出一条记录) */
backendBatchId?: string;
/** 该批次已提交、尚未终态的生图任务 id:切走再回来可据此对每一批各自续轮询(PMC#5/#10) */
@@ -582,7 +621,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; product_info?: CoverProductInfo; conversation_id?: string; reference_image_ids?: string[]; mention_refs?: CreationRef[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) => Promise<{ assets: Asset[]; conversation_id?: string; batch_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 图片创作仅接受路由显式带入的商品;不使用工作台全局当前商品。 */
imageProductId?: string;
@@ -619,6 +658,15 @@ export function ImageWorkbenchPage({
conversationScopeKeyRef.current = conversationScopeKey;
// 图片创作(image)默认留空,只靠 placeholder 引导;模特/平台仍预填模板省一步
const [prompt, setPrompt] = useState(mode === "image" ? "" : meta.promptTemplate(products[0]?.title || "商品"));
// 平台套图:商品信息草稿(切换商品时从商品资料重新带入;同一商品的资料刷新不覆盖用户编辑)
const [coverInfo, setCoverInfo] = useState<CoverInfoDraft>(() => coverInfoFromProduct(product));
useEffect(() => {
if (mode === "cover") setCoverInfo(coverInfoFromProduct(product));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, product?.id]);
const updateCoverInfo = (key: keyof CoverInfoDraft, value: string) => setCoverInfo((prev) => ({ ...prev, [key]: value }));
const coverInfoReady = !!(coverInfo.sellingPoints.trim() || coverInfo.effect.trim());
const coverSellingCount = splitSellingPoints(coverInfo.sellingPoints).length;
const [ratio, setRatio] = useState(meta.ratio);
// 手动输入比例:开启后用 W:H 两个输入框自定义,关闭则用预设 pill
const [ratioManual, setRatioManual] = useState(false);
@@ -686,17 +734,23 @@ export function ImageWorkbenchPage({
// 参考图:支持多张(可多选 / 多次追加),逐张可移除。提交时上传成 Asset 作生成参考。
const [refImages, setRefImages] = useState<{ name: string; url: string; file: File }[]>([]);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 图片创作 @ 引用保留实体 id;后端再回库取卖点和真实参考图,不依赖名字匹配。
const [mentionRefs, setMentionRefs] = useState<CreationRef[]>([]);
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
const [mentionTypeLabels, setMentionTypeLabels] = useState<Record<string, string>>({});
const mentionEditorRef = useRef<RichMentionEditorHandle>(null);
const mentionMenuRef = useRef<HTMLDivElement>(null);
// 生成后把结果面板滚到最新批次用的哨兵(PMC#14/#22)
const resultsEndRef = useRef<HTMLDivElement | null>(null);
// 生成结果图片放大预览
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
// 平台套图头部:按提示词搜索已生成的结果图(gridQuery / searchOpen 仅 cover 用;
// row40 后模特模式改单卡选择,已无网格可搜/排序,时间排序/模特筛选下拉一并移除)
// 平台筛选弹窗已随「选择平台」一起移除。
const [gridQuery, setGridQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
// YYX#12:平台套图结果筛选弹窗 —— 按平台筛选已生成的图(空集 = 不筛)
const [filterOpen, setFilterOpen] = useState(false);
const [platformFilter, setPlatformFilter] = useState<string[]>([]);
const visibleProducts = products.filter((item) => !(mode === "model" && isLocalLife(item)));
// 商品主图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
@@ -718,6 +772,49 @@ export function ImageWorkbenchPage({
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
const mentionName = (ref: CreationRef) => ref.name.split(" · ")[0].trim();
const openImageMentions = useCallback(async () => {
setMentionMenuOpen(true);
setMentionLoading(true);
try {
const result = await api.searchMentions({ limit: 6 });
setMentionResults(result.results);
setMentionTypeLabels(result.type_labels);
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "引用素材加载失败");
setMentionResults([]);
} finally {
setMentionLoading(false);
}
}, [onNotify]);
const selectImageMention = (ref: CreationRef) => {
setMentionRefs((prev) => prev.some((item) => item.type === ref.type && item.id === ref.id) ? prev : [...prev, ref]);
mentionEditorRef.current?.insertMention(ref, true);
setMentionMenuOpen(false);
};
const handleStudioPromptChange = (value: string) => {
setPrompt(value);
// 引用标签被用户删除时,同步解除结构化引用,避免看不见的素材仍参与生成。
setMentionRefs((prev) => prev.filter((ref) => value.includes(`@${mentionName(ref)}`)));
};
const mentionGroups = useMemo(() => {
const groups = new Map<string, CreationRef[]>();
mentionResults.forEach((ref) => groups.set(ref.type, [...(groups.get(ref.type) || []), ref]));
return Array.from(groups.entries());
}, [mentionResults]);
useEffect(() => {
if (!mentionMenuOpen) return;
const close = (event: MouseEvent) => {
if (!mentionMenuRef.current?.contains(event.target as Node)) setMentionMenuOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [mentionMenuOpen]);
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
const [priceMultiplier, setPriceMultiplier] = useState(1);
@@ -790,26 +887,17 @@ export function ImageWorkbenchPage({
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openMore]);
// YYX#12:点空白收起平台筛选弹窗
useEffect(() => {
if (!filterOpen) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".iw-filter-wrap")) setFilterOpen(false); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [filterOpen]);
const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4);
// PMC#5:平台套图(cover)对每个选中平台各起一批,实扣 = 平台数 × candidateCount × 单价。
// 预估必须乘平台数才与实扣口径一致(model/image 模式只生成 candidateCount 张,不乘)。
const estimateCount = mode === "cover" ? Math.max(1, pickedIds.length) * candidateCount : candidateCount;
// 平台套图已不再按平台拆批:每次提交一批 candidateCount 张,实扣 = candidateCount × 单价(三种模式同口径)。
const estimateCount = candidateCount;
// 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用
// 模特上身图需结合「商品图 + 模特图」合成,故必须先选商品且选一个模特,否则只是凭文字脑补
const canGenerate =
prompt.trim().length > 0 &&
(mode !== "model" || (!!product?.id && pickedIds.length > 0)) &&
// 平台套图:必须选商品 + 至少一个平台(P0③ 多选)
(mode !== "cover" || (!!product?.id && pickedIds.length > 0));
// 平台套图:必须选商品(商品图作参考)+ 至少填写核心卖点或商品作用之一
const canGenerate = mode === "cover"
? !!product?.id && coverInfoReady
: prompt.trim().length > 0 && (mode !== "model" || (!!product?.id && pickedIds.length > 0));
/* R100:工作台记录不再走 localStorage 持久化(1 小时过期、换浏览器即空 = 「任务中心有记录、
工作台丢」的根因)。模特/平台模式的批次流改从后端持久任务恢复(见下方 workbenchTasks effect),
@@ -919,6 +1007,7 @@ export function ImageWorkbenchPage({
productId: first.product_id || undefined,
modelId: first.model_id || undefined,
platformIds: platformKey ? [platformKey] : undefined,
productInfo: first.product_info || undefined,
backendBatchId: first.batch_id || undefined,
taskIds: live.map((t) => t.id),
rerunTaskIds: live.filter((t) => t.rerun).map((t) => t.id),
@@ -976,6 +1065,8 @@ export function ImageWorkbenchPage({
setActiveConvId(conv.id);
setBatches([]);
setPrompt(mode === "image" ? "" : meta.promptTemplate(product?.title || "商品"));
setMentionRefs([]);
setMentionMenuOpen(false);
setPickedIds([]);
} catch (err) {
// 不再静默吞错:把失败摆到用户面前(最常见原因 = 后端未更新,对话接口 404)
@@ -1053,6 +1144,8 @@ export function ImageWorkbenchPage({
setActiveConvId("");
setConversations([]);
setBatches([]);
setMentionRefs([]);
setMentionMenuOpen(false);
setRenamingId("");
setConvError("");
void loadConversations();
@@ -1069,10 +1162,14 @@ export function ImageWorkbenchPage({
modelId?: string;
modelName?: string;
platformIds?: string[];
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
/** 旧记录兼容:规范化平台 id(douyin/taobao…),仅重跑旧平台批次时带回 */
platformId?: string;
/** 平台套图:商品信息(卖点 / 作用 / 人群 / 规格 / 补充),透传后端注入主图提示词 */
productInfo?: CoverProductInfo;
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
refs?: { name: string; url: string; file?: File; assetId?: string }[];
/** 图片创作 @ 引用的实体;后端根据 id 解析为素材事实和参考图。 */
mentions?: CreationRef[];
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
reuseBatchId?: string;
/** PMC#25:单图重跑——把新图追加进这张已有批次卡(不清旧好图、不新开卡) */
@@ -1105,8 +1202,10 @@ export function ImageWorkbenchPage({
modelId: opts.modelId,
modelName: opts.modelName,
platformIds: opts.platformIds,
productInfo: opts.productInfo,
// 批次头回显「参考了哪些图」(存名+预览+已知 assetId,不存 file;上传成功后统一回写 assetId)
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId }))
refs: opts.refs?.map((r) => ({ name: r.name, url: r.url, assetId: r.assetId })),
mentions: opts.mentions
};
setBatches((prev) => [...prev, newBatch]);
}
@@ -1135,7 +1234,7 @@ export function ImageWorkbenchPage({
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// batch_id:重跑/补图带原批次 id → 后端沿用,记录归回原批次(刷新后不裂新聊天记录)
// onSubmitted:提交成功拿到任务 id 记进本批 pendingIds → 切走再回来由后端记录接续轮询(R100)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, product_info: opts.productInfo, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, mention_refs: opts.mentions, batch_id: opts.batchId, retry_of_task_id: opts.retryOfTaskId,
onSubmitted: (taskIds, submittedBatchId) => {
if (mode === "image" && submittedScopeKey !== conversationScopeKeyRef.current) return;
setBatches((prev) => prev.map((b) => {
@@ -1210,11 +1309,13 @@ export function ImageWorkbenchPage({
productTitle: mode === "image" ? imageProduct?.title : product?.title
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
for (const pid of pickedIds) {
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
}
// 不再选平台:一次提交一批,结构化商品信息走 product_info(后端注入电商主图提示词),
// prompt 为可读摘要(批次头展示 / 搜索用)。商品图参考链路不变(后端按 product_id 取商品图)。
void startBatch({
...base,
prompt: composeCoverPrompt(product?.title || "商品", coverInfo),
productInfo: coverInfoPayload(coverInfo)
});
return;
}
void startBatch({
@@ -1222,11 +1323,12 @@ export function ImageWorkbenchPage({
modelId: mode === "model" ? pickedIds[0] : undefined,
modelName: mode === "model" ? pickedModelName : undefined,
// 图片创作:把已选参考图带进这一批(startBatch 内上传并传给后端)
refs: mode === "image" && refImages.length ? refImages : undefined
refs: mode === "image" && refImages.length ? refImages : undefined,
mentions: mode === "image" && mentionRefs.length ? mentionRefs : undefined
});
// 图片创作:提交后清空输入栏(参考图 + 提示词文字),像对话一样「发完即清」,等下次输入(PMC#15)。
// 批次头会保留这次用过的提示词/参考图,信息不丢。模特/平台模式的提示词是按商品预填的模板,不清。
if (mode === "image") { setRefImages([]); setPrompt(""); }
if (mode === "image") { setRefImages([]); setMentionRefs([]); setPrompt(""); }
// 生成后自动把面板滚到最新批次,不用用户自己往下拖找(PMC#14/#22)。等新批次渲染出来再滚。
window.setTimeout(() => resultsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 80);
}
@@ -1244,8 +1346,10 @@ export function ImageWorkbenchPage({
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
productInfo: src.productInfo,
// 原批次的参考图 + 后端批次 id 一并带回:重跑仍参考原素材,且任务归回原批次不裂新记录
refs: src.refs,
mentions: src.mentions,
batchId: src.backendBatchId,
reuseBatchId: src.id
});
@@ -1321,6 +1425,7 @@ export function ImageWorkbenchPage({
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
productInfo: src.productInfo,
// 原批次的参考图 + 后端批次 id 一并带回:补的这张仍参考原素材,且归回原批次不裂新记录
refs: src.refs,
batchId: src.backendBatchId,
@@ -1381,12 +1486,8 @@ export function ImageWorkbenchPage({
const q = gridQuery.trim().toLowerCase();
list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q));
}
// YYX#12:筛选弹窗按平台筛选已生成的图(选了哪些平台就只看哪些)
if (mode === "cover" && platformFilter.length) {
list = list.filter((b) => (b.platformIds || []).some((p) => platformFilter.includes(p)));
}
return list;
}, [batches, productId, mode, gridQuery, platformFilter]);
}, [batches, productId, mode, gridQuery]);
const hasResults = productBatches.length > 0;
/* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ──
@@ -1533,42 +1634,6 @@ export function ImageWorkbenchPage({
);
}
/* ── 平台套图 · 多平台分组结果(P0③)──
每个平台一个 section 头(平台 logo + 名 + 批次数),其下挂该平台的批次卡。 */
function renderCoverGrouped(list: GenBatch[]) {
// 按平台 id 分组;无平台标签的批次归到「未分类」组(兜底,正常不出现)
const groups: Array<{ pid: string; batches: GenBatch[] }> = [];
for (const batch of list) {
const pid = batch.platformIds?.[0] || "_";
let g = groups.find((x) => x.pid === pid);
if (!g) { g = { pid, batches: [] }; groups.push(g); }
g.batches.push(batch);
}
return (
<>
{groups.map((g) => {
const plat = PLATFORM_OPTIONS.find((p) => p.id === g.pid);
return (
<Fragment key={g.pid}>
<div className="iw-cover-group-h">
{plat ? (
<>
<PlatformLogo p={plat} className="cg-logo" />
<span className="cg-name">{plat.name}</span>
</>
) : (
<span className="cg-name">未分类平台</span>
)}
<span className="cg-ct">{g.batches.length} 批</span>
</div>
{renderBatchCards(g.batches)}
</Fragment>
);
})}
</>
);
}
const featuredProducts = (() => {
const selected = visibleProducts.find((item) => item.id === productId);
const rest = visibleProducts.filter((item) => item.id !== productId);
@@ -1579,7 +1644,6 @@ export function ImageWorkbenchPage({
const rest = personAssets.filter((item) => item.id !== pickedIds[0]);
return (selected ? [selected, ...rest] : rest).slice(0, 3);
})();
const selectedPlatform = PLATFORM_OPTIONS.find((item) => item.id === pickedIds[0]);
const ratioOptions = mode === "model" ? MODEL_RATIO_OPTIONS : RATIO_OPTIONS;
const countOptions = mode === "model" ? MODEL_COUNT_OPTIONS : COVER_COUNT_OPTIONS;
const openProductLibrary = () => {
@@ -1758,12 +1822,40 @@ export function ImageWorkbenchPage({
</div>
)}
</div>
<textarea
className="studio-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
/>
<div className="studio-prompt-wrap" ref={mentionMenuRef}>
<RichMentionEditor
ref={mentionEditorRef}
className="studio-prompt"
value={prompt}
refs={mentionRefs}
placeholder="描述你想生成或修改的画面,@ 可引用商品、模特或已有素材……"
ariaLabel="图片创作提示词"
onChange={handleStudioPromptChange}
onAtTrigger={() => { void openImageMentions(); }}
/>
{mentionMenuOpen ? (
<div className="studio-mention-menu" role="dialog" aria-label="引用素材">
<div className="studio-mention-head">引用素材</div>
<div className="studio-mention-list">
{mentionLoading ? (
<span className="studio-mention-state">正在加载素材…</span>
) : mentionGroups.length === 0 ? (
<span className="studio-mention-state">暂无可引用的商品、模特或素材</span>
) : mentionGroups.map(([type, refs]) => (
<div className="studio-mention-group" key={type}>
<span>{mentionTypeLabels[type] || type}</span>
{refs.map((ref) => (
<button type="button" key={`${ref.type}:${ref.id}`} onClick={() => selectImageMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <i>{(mentionTypeLabels[ref.type] || ref.type).slice(0, 1)}</i>}
<b>{mentionName(ref)}</b>
</button>
))}
</div>
))}
</div>
</div>
) : null}
</div>
</div>
<div className="image-composer-footer">
<div className="image-composer-options">
@@ -1831,7 +1923,7 @@ export function ImageWorkbenchPage({
<div className="generator-layout">
<aside className="generator-panel">
<div className="asset-pair-grid">
<div className={`asset-pair-grid${mode === "cover" ? " single" : ""}`}>
<section className="control-section">
<div className="control-heading"><span className="step-number">1</span><strong>选择商品</strong></div>
<div className="product-choice-list">
@@ -1866,7 +1958,7 @@ export function ImageWorkbenchPage({
</div>
</section>
{mode === "model" ? (
{mode === "model" && (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择模特</strong></div>
<div className="model-choice-grid">
@@ -1895,33 +1987,24 @@ export function ImageWorkbenchPage({
</button>
</div>
</section>
) : (
<section className="control-section">
<div className="control-heading"><span className="step-number">2</span><strong>选择平台</strong></div>
<div className="yz-platform-grid">
{PLATFORM_OPTIONS.map((item) => (
<button
type="button"
key={item.id}
className={`platform-choice ${pickedIds.includes(item.id) ? "active" : ""}`}
onClick={() => togglePick(item.id)}
>
<span className="choice-check" />
<PlatformLogo p={item} />
<span>{item.name}</span>
</button>
))}
</div>
</section>
)}
</div>
<section className={`control-section generator-settings${mode === "cover" ? " platform-generator-settings" : ""}`}>
<div className="control-heading"><span className="step-number">3</span><strong>生成设置</strong></div>
<section className={`control-section generator-settings${mode === "cover" ? " cover-generator-settings" : ""}`}>
<div className="control-heading">
<span className="step-number">{mode === "cover" ? 2 : 3}</span>
<strong>生成设置</strong>
{mode === "cover" && product && (
<button type="button" className="cover-info-reset" title="用商品库中的卖点、描述、人群与规格重新填充" onClick={() => setCoverInfo(coverInfoFromProduct(product))}>
<RefreshCw size={12} />
重新带入
</button>
)}
</div>
<div className="field-group">
<div className="field-label">
<span>生成数量</span>
<small>{mode === "cover" ? "覆盖不同图位" : "单次最多 4 张"}</small>
<small>{mode === "cover" ? "依次覆盖主图 / 卖点 / 细节 / 场景" : "单次最多 4 张"}</small>
</div>
<div className="choice-row">
{countOptions.map((value) => (
@@ -1983,35 +2066,107 @@ export function ImageWorkbenchPage({
)}
</div>
)}
<div className="field-group">
<label className="field-label">
<span>{mode === "cover" ? "画面要求" : "提示词"}</span>
<small>{mode === "cover" ? "平台规范已自动载入" : "可继续修改"}</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>
{mode === "cover"
? (selectedPlatform ? `${selectedPlatform.name}规范` : "请选择平台")
: "自动匹配商品参考图"}
</span>
{mode === "cover" ? (
<>
{/* 商品信息(替代原「画面要求」):结构化提交 product_info,后端据此生成电商主图 */}
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-selling">
<span>核心卖点</span>
</label>
<textarea
id="cover-info-selling"
className="image-prompt cover-info-textarea"
value={coverInfo.sellingPoints}
onChange={(event) => updateCoverInfo("sellingPoints", event.target.value)}
placeholder={"例如:\n72 小时长效保湿\n无酒精,敏感肌可用"}
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-effect">
<span>商品功效</span>
</label>
<textarea
id="cover-info-effect"
className="image-prompt cover-info-textarea"
value={coverInfo.effect}
onChange={(event) => updateCoverInfo("effect", event.target.value)}
placeholder="例如:深层补水、舒缓干燥紧绷,让上妆更服帖"
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-audience">
<span>适用人群</span>
</label>
<input
id="cover-info-audience"
className="cover-info-input"
value={coverInfo.audience}
onChange={(event) => updateCoverInfo("audience", event.target.value)}
placeholder="例如:干皮 / 敏感肌、熬夜人群"
/>
</div>
<div className="field-group half">
<label className="field-label" htmlFor="cover-info-specs">
<span>规格参数</span>
</label>
<input
id="cover-info-specs"
className="cover-info-input"
value={coverInfo.specs}
onChange={(event) => updateCoverInfo("specs", event.target.value)}
placeholder="例如:200ml · 玻璃瓶装"
/>
</div>
<div className="field-group">
<label className="field-label" htmlFor="cover-info-notes">
<span>补充要求</span>
</label>
<input
id="cover-info-notes"
className="cover-info-input"
value={coverInfo.notes}
onChange={(event) => updateCoverInfo("notes", event.target.value)}
placeholder="例如:清透蓝白色调,浴室台面场景"
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span className={coverInfoReady ? "" : "cover-info-hint"}>
{!product
? "请先选择商品"
: coverInfoReady
? `引用商品图 ${product.images?.length || 0} 张 · 1:1 电商主图`
: "补充卖点后即可生成"}
</span>
</div>
</div>
</>
) : (
<div className="field-group">
<label className="field-label">
<span>提示词</span>
<small>可继续修改</small>
</label>
<textarea
className="image-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={`例如:${meta.promptTemplate(product?.title || "商品")}`}
/>
<div className="model-line">
<Pill label="模型" value={selectedImageModelLabel} options={imageModelPickerOptions} onSelect={setGenModel} />
<span>自动匹配商品参考图</span>
</div>
</div>
</div>
)}
</section>
<div className="generator-footer">
<button type="button" className="generate-button" onClick={runGenerate} disabled={!canGenerate}>
<WandSparkles />
<span>{mode === "cover" ? "生成平台套图" : "立即生成"}</span>
<span>{mode === "cover" ? "生成套图" : "立即生成"}</span>
</button>
<div className="cost-line">
<span>{mode === "cover" ? "生成成功后按实际数量结算" : "仅在生成成功后扣费"}</span>
<span>成功后扣费</span>
<strong>预计 {estimateFee(estimateCount)}</strong>
</div>
</div>
@@ -2021,21 +2176,11 @@ export function ImageWorkbenchPage({
<div className="result-topbar">
<strong>{mode === "cover" ? "套图预览" : "生成结果"}</strong>
<div className="result-topbar-tools">
<div className="result-tags">
{mode === "cover" ? (
<>
<span>{selectedPlatform?.name || "未选平台"}</span>
<span>{count} 个图位</span>
<span>统一视觉</span>
</>
) : (
<>
<span>{ratio}</span>
<span>{count} 张</span>
<span>自动保存至成品库</span>
</>
)}
</div>
{!searchOpen && (
<div className="result-tags">
<span>{mode === "cover" ? `${count} 张套图` : `${ratio} · ${count} 张`}</span>
</div>
)}
{mode === "cover" && (
<>
<div className="tb-search-wrap">
@@ -2053,43 +2198,17 @@ export function ImageWorkbenchPage({
<Search size={14} />
</button>
</div>
<div className={`tb-menu-wrap chip-wrap iw-filter-wrap${filterOpen ? " open" : ""}`} data-filter="platform">
<button className={`search-btn${platformFilter.length ? " active" : ""}`} type="button" title="按平台筛选" onClick={() => setFilterOpen((v) => !v)}>
<SlidersHorizontal size={14} />
{platformFilter.length > 0 && <span className="iw-filter-count">{platformFilter.length}</span>}
</button>
<div className="chip-menu align-right iw-filter-menu">
<div className="iw-filter-h">
<span className="mono">按平台筛选</span>
<button type="button" className="iw-filter-clear" disabled={!platformFilter.length} onClick={() => setPlatformFilter([])}>清空</button>
</div>
<div className="iw-filter-grid">
{PLATFORM_OPTIONS.map((p) => (
<button
type="button"
key={p.id}
className={`iw-filter-opt${platformFilter.includes(p.id) ? " on" : ""}`}
onClick={() => setPlatformFilter((prev) => (prev.includes(p.id) ? prev.filter((x) => x !== p.id) : [...prev, p.id]))}
>
<PlatformLogo p={p} />
<span className="nm">{p.name}</span>
<Check className="ck" size={12} />
</button>
))}
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className={`result-canvas${hasResults ? " has-results" : ""}`}>
{mode === "cover" && (platformFilter.length > 0 || gridQuery.trim()) && (
{mode === "cover" && gridQuery.trim() && (
<div className="iw-filter-notice">
<span className="ifn-text">当前已开启筛选,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setPlatformFilter([]); setGridQuery(""); setSearchOpen(false); }}>
<span className="ifn-text">当前已开启搜索,部分内容可能隐藏</span>
<button type="button" className="ifn-clear" onClick={() => { setGridQuery(""); setSearchOpen(false); }}>
<X size={12} />
清空筛选
清空搜索
</button>
</div>
)}
@@ -2099,33 +2218,35 @@ export function ImageWorkbenchPage({
<strong>{mode === "cover" ? "还没有套图结果" : "等待生成"}</strong>
<p>
{mode === "cover"
? "选择平台后,系统会按对应图位和尺寸生成一组可继续编辑的电商素材。"
? "填写商品卖点与作用后,系统会结合商品图生成主图、卖点图、细节图与场景图。"
: "选择模特并确认设置后开始生成,结果会按版本保留,可随时切换使用。"}
</p>
</div>
) : (
<>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
{mode === "cover" ? renderCoverGrouped(productBatches) : renderBatchCards(productBatches)}
{renderBatchCards(productBatches)}
<div ref={resultsEndRef} aria-hidden="true" />
</>
)}
</div>
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>主图</span><strong>白底 / 场景各 1 张</strong></div>
<div className="result-stat"><span>卖点证明</span><strong>2 个核心卖点</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
{hasResults && (
<div className="result-footer">
{mode === "cover" ? (
<>
<div className="result-stat"><span>图位</span><strong>主图 / 卖点 / 细节 / 场景</strong></div>
<div className="result-stat"><span>卖点依据</span><strong>{coverSellingCount ? `${coverSellingCount} 个核心卖点` : coverInfo.effect.trim() ? "商品功效" : "待填写"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 平台套图</strong></div>
</>
) : (
<>
<div className="result-stat"><span>人物一致性</span><strong>{pickedModelName || "锁定所选模特"}</strong></div>
<div className="result-stat"><span>商品参考</span><strong>{product ? `${product.images?.length || 0} 张已关联` : "待选择商品"}</strong></div>
<div className="result-stat"><span>输出位置</span><strong>成品库 / 模特上身图</strong></div>
</>
)}
</div>
)}
</section>
</div>
@@ -2167,14 +2288,16 @@ export function ImageWorkbenchPage({
coverUrl={productCoverUrl}
onClose={() => setPlOpen(false)}
onConfirm={() => {
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特/平台)
// 第一个选中作为当前商品;若选了多个,各商品另起一批(沿用当前提示词/比例/张数/模特;
// 平台套图则用各自商品资料带入的商品信息 + 当前补充要求)
if (plDraft.length) {
setProductId(plDraft[0]);
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : mode === "cover" ? pickedIds.length > 0 : true) && prompt.trim()) {
if (plDraft.length > 1 && (mode === "model" ? pickedIds.length > 0 : true) && (mode === "cover" || prompt.trim())) {
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
const info = { ...coverInfoFromProduct(p), notes: coverInfo.notes };
void startBatch({ prompt: composeCoverPrompt(p?.title || "商品", info), ratio, count: candidateCount, productId: pid, productTitle: p?.title, productInfo: coverInfoPayload(info) });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
+84 -23
View File
@@ -700,17 +700,27 @@ function strategyDocumentBody(payload: Record<string, unknown>): string {
}
function planDocumentBody(payload: Record<string, unknown>): string {
const goal = String(payload.goal || "").trim();
const duration = String(payload.duration || "").trim();
const concept = String(payload.concept || "").trim();
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
const voice = coerceVoiceChars(payload.voice_chars);
const pointText = points.length ? points.map((point) => `- ${point}`).join("\n") : "—";
const timelineText = timeline.length
? timeline.map((item) => `- ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}${item.desc ? `:${item.desc}` : ""}`).join("\n")
? timeline.map((item) => [
`### ${formatDocumentTime(item.start)}–${formatDocumentTime(item.end)} 秒 · ${item.stage}`,
`- 画面 / 剧情:${item.visual || item.desc || "—"}`,
`- 动作 / 对白:${item.action_dialogue || "—"}`,
`- 商品 / 卖点:${item.product || "—"}`,
`- 段落作用:${item.purpose || "—"}`,
].join("\n")).join("\n\n")
: "—";
const voiceText = voice.length === 2 ? `${voice[0]}–${voice[1]} 字,预计覆盖约 90% 时长` : "—";
return [
"# 视频最终方案",
"# 视频架构",
`## 创作概览\n- 视频目标:${goal || "—"}\n- 目标时长:${duration || "—"}\n- 创意概念:${concept || "—"}`,
`## 主打卖点 USP\n${usp || "—"}`,
`## 核心支撑\n${pointText}`,
`## 时间轴\n${timelineText}`,
@@ -999,7 +1009,16 @@ function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
);
}
type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string };
type PlanTimelineItem = {
start: number;
end: number;
stage: string;
desc?: string;
visual?: string;
action_dialogue?: string;
product?: string;
purpose?: string;
};
function coercePlanPoints(raw: unknown): string[] {
if (Array.isArray(raw)) {
@@ -1039,6 +1058,9 @@ function coerceVoiceChars(raw: unknown): number[] {
}
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
const goal = String(payload.goal || "").trim();
const duration = String(payload.duration || "").trim();
const concept = String(payload.concept || "").trim();
const usp = strategyField(payload, "usp", "主打卖点", "卖点");
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
@@ -1049,17 +1071,17 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
return (
<section className="omni-video-plan-card">
<header className="omni-video-plan-head">
<strong>视频最终方案</strong>
<strong>视频架构</strong>
<div className="omni-doc-head-actions">
<span>确认后进入出片参数核对</span>
<span>确认后撰写完整 Prompt</span>
<button
type="button"
className="omni-doc-download"
title="下载"
aria-label="下载视频最终方案"
aria-label="下载视频架构"
onClick={() => void downloadSessionResource({
kind: "document",
title: "视频最终方案.md",
title: "视频架构.md",
body: planDocumentBody(payload),
})}
>
@@ -1067,6 +1089,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
</button>
</div>
</header>
<section className="omni-plan-section omni-plan-overview">
<strong>创作概览</strong>
<div className="omni-plan-overview-grid">
<span><b>视频目标</b><em>{goal || "—"}</em></span>
<span><b>目标时长</b><em>{duration || "—"}</em></span>
<span><b>创意概念</b><em>{concept || "—"}</em></span>
</div>
</section>
<section className="omni-plan-section">
<strong>卖点做减法</strong>
<div className="omni-plan-points">
@@ -1087,11 +1117,14 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
<strong>Hook 只负责留人,正文负责说服</strong>
<div className="omni-plan-timeline">
{timeline.map((item, index) => (
<span key={`${item.stage}-${index}`}>
<span className="omni-plan-timeline-item" key={`${item.stage}-${index}`}>
<b>
{format(item.start)}–{format(item.end)} 秒 · {item.stage}
</b>
{item.desc || ""}
<em><i>画面 / 剧情</i>{item.visual || item.desc || "—"}</em>
<em><i>动作 / 对白</i>{item.action_dialogue || "—"}</em>
<em><i>商品 / 卖点</i>{item.product || "—"}</em>
<em><i>段落作用</i>{item.purpose || "—"}</em>
</span>
))}
</div>
@@ -1426,6 +1459,9 @@ function ElicitCard({
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
{Number(message.payload?.estimated_credits || 0) > 0
? ` 预计 ${Number(message.payload.estimated_credits)} 积分。`
: ""}
</span>
<button
type="button"
@@ -1834,6 +1870,20 @@ function ElicitCard({
<span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
</header>
<div className="omni-elicit-body">
{interaction === "product_brief_review" ? (
<div className="omni-product-brief-list">
{((message.payload.items as Array<Record<string, unknown>> | undefined) || []).map((item, index) => {
const status = String(item.status || "missing");
const statusLabel = status === "ready" ? "已获取" : status === "not_needed" ? "暂不需要" : "待补充";
return (
<div className={`omni-product-brief-item is-${status}`} key={`${String(item.label || "商品信息")}-${index}`}>
<span><b>{String(item.label || "商品信息")}</b>{item.value ? <em>{String(item.value)}</em> : null}</span>
<i>{statusLabel}</i>
</div>
);
})}
</div>
) : null}
{fields.map((field) => {
const value = answers[field.key];
return (
@@ -1976,14 +2026,18 @@ function ConfirmCard({
const snapshot = { ...sessionParams, ...payloadParams };
const [draft, setDraft] = useState(snapshot);
const [initialDuration] = useState(snapshot.duration || "");
const [initialModel] = useState(snapshot.model || "");
const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged =
cardIsVideo
&& Boolean(draft.duration)
&& Boolean(initialDuration)
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
const modelChanged = cardIsVideo && Boolean(initialModel) && Boolean(draft.model) && draft.model !== initialModel;
const needsRebuild = durationChanged || modelChanged;
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
const generationPlan = (message.payload.generation_plan as Record<string, unknown> | undefined) || {};
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
const summary = paramLine(draft, cardIsVideo) || "当前参数";
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
@@ -2018,7 +2072,7 @@ function ConfirmCard({
: generationPhase === "failed" ? "可重新确认方案后再生成"
: generationPhase === "running" ? "正在出片,请稍候"
: generationPhase === "submitted" ? "正在提交生成"
: "确认前可以改参数。改时长会按新时长重写脚本。";
: "确认前可以改参数。改时长或模型会同步更新视频架构与 Prompt。";
const foot =
generationPhase === "done" ? "已完成出片"
: generationPhase === "failed" ? "出片失败"
@@ -2029,8 +2083,8 @@ function ConfirmCard({
: generationPhase === "failed" ? "生成失败"
: generationPhase === "running" || generationPhase === "submitted"
? "生成中"
: durationChanged
? "确认并重写脚本"
: needsRebuild
? "确认并同步架构"
: String(message.payload.label || "开始生成");
return (
@@ -2056,18 +2110,21 @@ function ConfirmCard({
/>
</div>
)}
{submitted ? null : durationChanged ? (
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
{submitted ? null : needsRebuild ? (
<p className="omni-confirm-hint">参数变化会同步更新受影响的视频架构与 Prompt,不会直接出片。</p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">
{durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。
{draft.duration === snapshot.duration && generationPlan.note
? String(generationPlan.note)
: `${durationSeconds} 秒将分 ${Math.ceil(durationSeconds / 30)} 段生成后自动合并。`}
{credits > 0 ? ` 预计共 ${credits} 积分。` : ""}
</p>
) : null}
<div className="omni-confirm-foot">
<span>{foot}</span>
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
{buttonLabel}
{!submitted && !durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
{!submitted && !needsRebuild && credits > 0 ? <i>约 {credits} 积分</i> : null}
</button>
</div>
</section>
@@ -3303,10 +3360,10 @@ export function OmniSessionPage({
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
);
if (result.regenerate) {
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
notify("info", "参数已更新,正在同步视频架构与 Prompt");
void send({
kind: "text",
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
text: `生成参数已更新为:${paramLine(nextParams, true)}。请只更新受影响的视频架构与 Prompt,保留已经确认的商品信息、角色图和未受影响的段落。`,
});
return;
}
@@ -3965,15 +4022,19 @@ export function OmniSessionPage({
) : mentionResults.length === 0 ? (
<strong>这类还没有可引用的内容</strong>
) : (
mentionResults.map((ref) => (
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
mentionResults.map((ref) => {
const filename = ref.name.split(" · ")[0];
const typeLabel = typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label;
return (
<button type="button" key={ref.id} title={filename} onClick={() => insertMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
<span>
{ref.name.split(" · ")[0]}
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
<strong>{filename}</strong>
<small>{typeLabel}</small>
</span>
</button>
))
);
})
)}
</div>
</div>