diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2a58ede..5b3683f 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -121,6 +121,13 @@ export function App() { const [notice, setNotice] = useState(null); const [loading, setLoading] = useState(false); + // 右上角 toast 自动消失:成功/信息 3s,错误 5s(此前 notice 不会自己清,导致「提示一直挂着」) + useEffect(() => { + if (!notice) return; + const timer = setTimeout(() => setNotice(null), notice.type === "error" ? 5000 : 3000); + return () => clearTimeout(timer); + }, [notice]); + const activeProject = useMemo( () => projects.find((project) => project.id === activeProjectId) || projects[0], [projects, activeProjectId] @@ -535,6 +542,7 @@ export function App() { onUploadImage={(formData) => action(() => api.uploadProductImage(activeProduct.id, formData), "商品图已上传")} onDeleteImage={(imageId) => action(() => api.deleteProductImage(activeProduct.id, imageId), "商品图已移除")} onGenerateImages={generateImages} + onAdoptTriView={(asset) => action(() => api.updateProduct(activeProduct.id, { cover_asset: asset.id }), "三视图已采用为商品图")} /> ); case "projects": @@ -553,11 +561,13 @@ export function App() { navigate("projects")} onCreate={async (payload) => { const created = await action(() => api.createProject(payload), "项目已创建"); if (created) navigate("pipeline", { projectId: created.id }); }} + onCreateProduct={(payload) => action(() => api.createProduct(payload), "")} /> ); case "pipeline": @@ -618,11 +628,11 @@ export function App() { case "assetFactory": return ; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 0497068..32617b4 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -719,6 +719,84 @@ 0%, 100% { opacity: 1; } 50% { opacity: .55; } } +/* 行30:生成中占位 · 旋转 spinner icon(替代「生成中…」文字) */ +.image-workbench .gen-image .gen-loading { + display: grid; place-items: center; +} +.image-workbench .gen-image .gen-loading .spinner { + width: 24px; height: 24px; +} + +/* ════════════════════════════════════════════════ + 行31/32/33 · 批次卡 + 气泡菜单 + ════════════════════════════════════════════════ */ +/* 批次卡:复用 .gen-card,批次间拉开间距 */ +.image-workbench .gen-batch-card + .gen-batch-card { margin-top: 18px; } +/* 批次头(数量 + 状态) */ +.image-workbench .gen-batch-h { + display: flex; align-items: center; gap: 10px; +} +.image-workbench .gen-batch-h .b-pic { + flex-shrink: 0; + width: 28px; height: 28px; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + display: grid; place-items: center; + color: var(--heat); + font-family: var(--font-mono); font-size: 12px; font-weight: 600; +} +.image-workbench .gen-batch-h .b-meta { flex: 1; min-width: 0; } +.image-workbench .gen-batch-h .b-nm { font-size: 13px; font-weight: 600; color: var(--accent-black); } +.image-workbench .gen-batch-h .b-info { + margin-top: 2px; + font-family: var(--font-mono); font-size: 12px; + color: var(--black-alpha-48); letter-spacing: .02em; +} +/* 行30:批次底部操作行 · 与上方图片网格拉开间距(原 .gen-card-actions 仅 4px,过小) */ +.image-workbench .gen-batch-actions { + display: flex; align-items: center; gap: 8px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--border-faint); +} + +/* 行32③ · 单图「更多」气泡 + 批次底部「更多」气泡(hover 展开) */ +.image-workbench .gen-img-more, +.image-workbench .gen-batch-more { position: relative; display: inline-flex; } +.image-workbench .gen-bubble { + position: absolute; top: calc(100% + 6px); right: 0; + min-width: 168px; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + box-shadow: 0 6px 24px rgba(0, 0, 0, .08); + padding: 4px; + display: none; + z-index: 40; +} +/* 批次底部气泡向上弹出(底部空间不足) */ +.image-workbench .gen-bubble.gen-bubble-up { top: auto; bottom: calc(100% + 6px); } +.image-workbench .gen-img-more:hover .gen-bubble, +.image-workbench .gen-img-more:focus-within .gen-bubble, +.image-workbench .gen-batch-more:hover .gen-bubble, +.image-workbench .gen-batch-more:focus-within .gen-bubble { display: block; } +.image-workbench .gen-bubble-item { + width: 100%; + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; + border: 0; border-radius: var(--r-sm); + background: transparent; + font-size: 13px; color: var(--accent-black); + font-family: inherit; text-align: left; cursor: pointer; + transition: background var(--t-base), color var(--t-base); +} +.image-workbench .gen-bubble-item svg { width: 13px; height: 13px; flex-shrink: 0; } +.image-workbench .gen-bubble-item:hover { background: var(--background-lighter); } +.image-workbench .gen-bubble-item.danger { color: var(--accent-crimson); } +.image-workbench .gen-bubble-item.danger:hover { background: var(--crimson-bg); } +.image-workbench .gen-bubble-item:disabled { color: var(--black-alpha-32); cursor: not-allowed; } +.image-workbench .gen-bubble-item:disabled:hover { background: transparent; } /* ════════════════════════════════════════════════ mode=image · 对话流形态(基线 image-optimize.html · design.md §4.13) diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx index e2cbbf9..d208a65 100644 --- a/core/frontend/src/components/overlays.tsx +++ b/core/frontend/src/components/overlays.tsx @@ -1,6 +1,6 @@ import { useEffect, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { CheckCircle2, Music, Shield, X } from "lucide-react"; +import { CheckCircle2, Music, Shield, Trash2, X } from "lucide-react"; // 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。 // 多浮层叠开用计数,最后一个关闭才解锁。 @@ -95,11 +95,13 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer ); } -export function ConfirmModal({ open, title, detail, confirmText, onCancel, onConfirm }: { +export function ConfirmModal({ open, title, detail, confirmText, subtitle = "// CONFIRM", icon, onCancel, onConfirm }: { open: boolean; title: string; detail: string; confirmText: string; + subtitle?: string; + icon?: ReactNode; onCancel: () => void; onConfirm: () => void | Promise; }) { @@ -110,7 +112,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
event.stopPropagation()}> ++ -
{title}// CONFIRM
+
{icon ?? }
{title}{subtitle}
{detail}
diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index 7268a04..ca3b2b9 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -159,9 +159,10 @@ .ov-tri-pop .prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); } /* 仅预览(未采用):黑色描边,无徽标 */ .ov-tri-pop .prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; } - .ov-tri-pop .prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); letter-spacing: .02em; } - .ov-tri-pop .prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; } - .ov-tri-pop .prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; } + /* 缩略图带真图时,版本号加深色底片确保可读 */ + .ov-tri-pop .prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 12px; color: var(--accent-white); letter-spacing: .02em; padding: 0 4px; border-radius: 3px; background: rgba(21,20,15,.56); } + .ov-tri-pop .prod-preview-history .h-thumb.adopted .v { font-weight: 600; } + .ov-tri-pop .prod-preview-history .h-thumb.previewing .v { font-weight: 600; } .ov-tri-pop .prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 12px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; } .ov-tri-pop .prod-preview-history .h-thumb.adopted .badge { display: block; } diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index cd26def..4d971e0 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -11,9 +11,14 @@ .product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; } .product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); } .product-thumb { aspect-ratio: 1.4 / 1; } + /* 缩略区铺满卡片宽度:去掉 .placeholder 的内描边/内圆角(否则顶部/四角露白边), + 卡片本身已 overflow:hidden 负责裁切外圆角 */ + .product-card .product-thumb { border: 0; border-radius: 0; } /* 真实主图:铺满缩略区(等比裁切),不再露白底/占位文字 */ .product-thumb.has-real-media { position: relative; overflow: hidden; background: var(--background-lighter); } .product-thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; } + /* mock 占位图同样真正铺满:cover 已在 design-restraint 定义,这里确保无内边距/无白边 */ + .product-card .product-thumb.has-mock-media { background-size: cover; background-position: center; padding: 0; } .product-body { padding: 14px 14px 12px; flex: 1; } .product-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .product-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; } @@ -45,8 +50,10 @@ body.edit-mode .product-card .card-check { display: grid; } body.edit-mode .product-card.selected .card-check { background: var(--heat); border-color: var(--heat); } body.edit-mode .product-card.selected .card-check svg { visibility: visible; } body.edit-mode .product-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; } -body.edit-mode .product-footer .stat, +/* 编辑模式只隐藏单卡删除按钮(改走多选 + 批量删除);底部「素材N/视频N」统计必须保留, + 且视频计数在编辑态不可点击(避免误触跳详情),仅作展示。 */ body.edit-mode .product-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; } +body.edit-mode .product-footer button.stat-link { pointer-events: none; } .bulk-bar { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); diff --git a/core/frontend/src/project-wizard-page.css b/core/frontend/src/project-wizard-page.css index c33108b..9a39145 100644 --- a/core/frontend/src/project-wizard-page.css +++ b/core/frontend/src/project-wizard-page.css @@ -292,8 +292,13 @@ .reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; } .reco-bubble .dismiss:hover { color: var(--accent-black); } - /* ── 底部「开始」CTA ── */ - .wiz-start-bar { display: flex; justify-content: flex-end; padding: 20px 0 8px; } + /* ── 底部「开始」CTA(sticky 固定:随内容滚动时常驻视口底部,不被卷走)── */ + .wiz-start-bar { + display: flex; justify-content: flex-end; + position: sticky; bottom: 0; z-index: 3; + margin-top: 6px; padding: 16px 0; + background: linear-gradient(to top, var(--background-base) 60%, transparent); + } .wiz-start-bar .btn-start { height: 44px; padding: 0 36px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--shadow-cta); display: inline-flex; align-items: center; gap: 8px; font-family: inherit; transition: box-shadow var(--t-base), opacity var(--t-base); } .wiz-start-bar .btn-start:hover:not(.disabled) { box-shadow: var(--shadow-cta-hover); } .wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; } @@ -310,3 +315,14 @@ .pp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } } + +/* ── 新建商品抽屉(Drawer portal 到 body,样式不能 scope 进 .project-wizard-page)── */ +.drawer .np-point-add { display: flex; gap: 8px; align-items: center; } +.drawer .np-point-add .input { flex: 1; min-width: 0; } +.drawer .np-drawer-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--border-faint); } +.drawer .field-hint.err { color: var(--accent-crimson); margin-top: 4px; } +.drawer .input.error { border-color: var(--accent-crimson); } +.drawer .theme-pill { display: inline-flex; gap: 6px; align-items: center; height: 32px; padding: 0 14px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; color: var(--accent-black); transition: background var(--t-base), border-color var(--t-base), color var(--t-base); } +.drawer .theme-pill:hover { background: var(--background-lighter); } +.drawer .theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat); font-weight: 600; } +.drawer .theme-pill svg { width: 12px; height: 12px; } diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index f1ed63c..f0fb41f 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -22,7 +22,7 @@ import { X } from "lucide-react"; import type { AITask, Asset, ModelConfig, Product } from "../types"; -import { MediaLightbox } from "../components/overlays"; +import { MediaLightbox, SuccessModal } from "../components/overlays"; import { Pager } from "../components/pager"; import type { Page } from "./route-config"; import { statusPill } from "./stage-config"; @@ -454,6 +454,20 @@ const PLATFORM_OPTIONS = [ { id: "al", name: "1688", logo: "阿" } ]; +/* 一次生成/重跑 = 一个批次:各自独立展示自己的图、状态、加入资产库切换;支持多批并行跑。 + 行31(并发提交) / 行32(重跑追加新批次 + 气泡菜单 + 加入/取消切换) / 行33(多批次持久化恢复)。 */ +type GenBatch = { + id: string; + prompt: string; + ratio: string; + count: number; + status: "generating" | "done" | "failed"; + results: Asset[]; + /** 批次级:是否已"加入资产库"(可来回切) */ + adopted: boolean; + ts: number; +}; + export function ImageWorkbenchPage({ mode, products, @@ -462,7 +476,8 @@ export function ImageWorkbenchPage({ onBack, navigate, onGenerate, - onResume + onResume, + initialProductId }: { mode: WorkMode; products: Product[]; @@ -472,17 +487,22 @@ export function ImageWorkbenchPage({ navigate?: (page: Page) => void; onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; + /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ + initialProductId?: string; }) { const meta = MODE_META[mode]; - const [productId, setProductId] = useState(products[0]?.id || ""); + const [productId, setProductId] = useState(initialProductId || products[0]?.id || ""); const product = products.find((item) => item.id === productId) || products[0]; const [prompt, setPrompt] = useState(meta.promptTemplate(products[0]?.title || "商品")); const [ratio, setRatio] = useState(meta.ratio); const [style, setStyle] = useState("auto"); const [count, setCount] = useState(mode === "image" ? "4" : "4"); + // 行29:模特/平台改单选(原 string[] 多选 → 单个 id;沿用数组结构最小改动,长度恒为 0/1) const [pickedIds, setPickedIds] = useState([]); - const [generating, setGenerating] = useState(false); - const [results, setResults] = useState(null); + // 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating + const [batches, setBatches] = useState([]); + // 加入资产库成功提示(行32④) + const [adoptNotice, setAdoptNotice] = useState<{ count: number } | null>(null); const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null); const refInputRef = useRef(null); // 生成结果图片放大预览 @@ -514,49 +534,192 @@ export function ImageWorkbenchPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId, mode]); + // 行29:单选——再点同一张取消,点别张替换(长度恒为 0 或 1) function togglePick(id: string) { - setPickedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + setPickedIds((prev) => (prev[0] === id ? [] : [id])); } const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); - const canGenerate = prompt.trim().length > 0 && !generating; + // 行31:并发提交——只要 prompt 非空就能再次提交,不再因"有批次在跑"被禁用 + const canGenerate = prompt.trim().length > 0; + const anyGenerating = batches.some((b) => b.status === "generating"); - async function runGenerate() { - if (!canGenerate) return; - setGenerating(true); + /* 多批次本地持久化(行33):用 ai-tools 私有 key,不与 App 的单批 `airshelf:imgwb:{mode}` 抢同一槽。 + 已出图的批次直接回显;App 不可改,故"刷新时仍在跑"的批次只能尽力恢复(见回报)。 */ + const batchKey = `airshelf:imgwb:batches:${mode}`; + function persistBatches(list: GenBatch[]) { try { - const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount }); - // 成功:渲染真图;失败(null):保留原占位,不改 results - if (result?.assets) setResults(result.assets); - } finally { - setGenerating(false); + // 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死) + const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status })); + localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() })); + } catch { + /* localStorage 不可用时静默降级 */ } } - /* 跨刷新恢复:读本地残留(由 App.generateImages 落盘),先回显已出的图, - 再对还在跑的任务继续轮询——刷新页面不再丢"生成中"与已出结果。 */ + async function runGenerate() { + if (!canGenerate) return; + const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const newBatch: GenBatch = { + id: batchId, + prompt: prompt.trim(), + ratio, + count: candidateCount, + status: "generating", + results: [], + adopted: false, + ts: Date.now() + }; + // 追加新批次(行32②:不覆盖,在下面新增一行) + setBatches((prev) => [...prev, newBatch]); + try { + const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount }); + setBatches((prev) => { + const next = prev.map((b) => + b.id === batchId + ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } + : b + ); + persistBatches(next); + return next; + }); + } catch { + setBatches((prev) => { + const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b)); + persistBatches(next); + return next; + }); + } + } + + /* 重跑指定批次(行32①②):用该批次的参数新起一个批次追加到列表末尾,原批次保留。 */ + async function rerunBatch(src: GenBatch) { + const batchId = `b-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const newBatch: GenBatch = { + id: batchId, + prompt: src.prompt, + ratio: src.ratio, + count: src.count, + status: "generating", + results: [], + adopted: false, + ts: Date.now() + }; + setBatches((prev) => [...prev, newBatch]); + try { + const result = await onGenerate({ prompt: src.prompt, mode, count: src.count }); + setBatches((prev) => { + const next = prev.map((b) => + b.id === batchId + ? { ...b, status: result?.assets ? ("done" as const) : ("failed" as const), results: result?.assets || [] } + : b + ); + persistBatches(next); + return next; + }); + } catch { + setBatches((prev) => { + const next = prev.map((b) => (b.id === batchId ? { ...b, status: "failed" as const } : b)); + persistBatches(next); + return next; + }); + } + } + + /* 删除整个批次(行32③:批次气泡"删除当前批次") */ + function removeBatch(id: string) { + setBatches((prev) => { + const next = prev.filter((b) => b.id !== id); + persistBatches(next); + return next; + }); + } + + /* 删除批次内单张图(行32③:单图气泡"删除") */ + function removeImageFromBatch(batchId: string, assetId: string) { + setBatches((prev) => { + const next = prev.map((b) => + b.id === batchId ? { ...b, results: b.results.filter((a) => a.id !== assetId) } : b + ); + persistBatches(next); + return next; + }); + } + + /* 加入资产库 / 取消加入(行32④:批次级来回切 + 成功提示) */ + function toggleAdoptBatch(batchId: string) { + setBatches((prev) => { + const target = prev.find((b) => b.id === batchId); + const willAdopt = !target?.adopted; + const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b)); + persistBatches(next); + if (willAdopt && target) setAdoptNotice({ count: target.results.length }); + return next; + }); + } + + /* 加入资产库 / 取消(单图级,行32④:单图气泡"加入资产库",来回切) */ + function toggleAdoptImage(batchId: string, _assetId: string) { + // 单图采用以批次级 adopted 表达(后端无单图采用接口),提示单张 + setBatches((prev) => { + const target = prev.find((b) => b.id === batchId); + const willAdopt = !target?.adopted; + const next = prev.map((b) => (b.id === batchId ? { ...b, adopted: willAdopt } : b)); + persistBatches(next); + if (willAdopt) setAdoptNotice({ count: 1 }); + return next; + }); + } + + /* 跨刷新恢复(行33):先读 ai-tools 多批次残留回显已出的批次; + 再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */ useEffect(() => { let cancelled = false; + // 1) 多批次结果回显 + try { + const raw = localStorage.getItem(batchKey); + if (raw) { + const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number }; + if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) { + const restored = (saved.batches || []).filter((b) => b.results && b.results.length); + if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const }))); + } + } + } catch { + /* 残留解析失败则忽略 */ + } + // 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报) try { const raw = localStorage.getItem(`airshelf:imgwb:${mode}`); if (!raw) return; - const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; ts?: number }; - if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; // 1 小时前的残留不恢复 - if (saved.results && saved.results.length) setResults(saved.results); + const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number }; + if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; const pending = saved.pending || []; if (pending.length && onResume) { - setGenerating(true); + const batchId = `b-resume-${Date.now()}`; + setBatches((prev) => { + // 已由多批次残留恢复过结果就不重复加占位 + if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev; + return [ + ...prev, + { id: batchId, prompt: meta.promptTemplate(product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now() } + ]; + }); onResume(mode, pending) .then((res) => { - if (!cancelled && res?.assets) setResults(res.assets); - }) - .finally(() => { - if (!cancelled) setGenerating(false); + if (cancelled) return; + setBatches((prev) => { + const next = prev.map((b) => + b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b + ); + persistBatches(next); + return next; + }); }); } } catch { - /* 残留解析失败则忽略,按初始态渲染 */ + /* 残留解析失败则忽略 */ } return () => { cancelled = true; @@ -565,50 +728,118 @@ export function ImageWorkbenchPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); - const hasResults = !!(results && results.length > 0); + const hasResults = batches.length > 0; - /* ── 共享:生成结果网格(§4.18 .gen-card · 三 mode 统一渲染真图)── */ - function renderResultGrid() { - const cols = (results?.length ?? candidateCount) >= 4 ? 4 : 2; + /* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */ + function renderBatchGrid(batch: GenBatch) { + const generating = batch.status === "generating"; + const n = batch.results.length || batch.count; + const cols = n >= 4 ? 4 : 2; + const ratioBatchVar = batch.ratio.replace(":", " / "); return ( - <> - setPreview(null)} />
- {(hasResults - ? results!.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url })) - : Array.from({ length: candidateCount }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined })) - ).map(({ key, index, url }) => ( + {(batch.results.length + ? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id })) + : Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "" })) + ).map(({ key, index, url, assetId }) => (
{url ? ( {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : (
- - {generating ? "生成中…" : `${ratio} · #${index + 1}`} - + {generating ? ( + // 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字 + + + + ) : ( + {batch.ratio} · #{index + 1} + )}
)} -
- - {url && ( + {url && ( +
- )} - {url && ( - - )} -
+ {/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */} +
+ +
+ + +
+
+
+ )}
))}
+ ); + } + + /* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */ + function renderBatchList() { + return ( + <> + setPreview(null)} /> + {batches.map((batch) => { + const generating = batch.status === "generating"; + const failed = batch.status === "failed"; + return ( +
+
+ {batch.count}× +
+
{batch.count} 张 · {batch.ratio}
+
+ {generating ? "生成中" : failed ? "失败" : "已完成"} + {batch.adopted && !generating && !failed ? " · 已加入资产库" : ""} +
+
+
+ {renderBatchGrid(batch)} + {/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */} +
+ + + {/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */} +
+ +
+ + +
+
+
+
+ ); + })} ); } @@ -628,7 +859,7 @@ export function ImageWorkbenchPage({ 返回
- @@ -656,37 +887,53 @@ export function ImageWorkbenchPage({
{hasResults ? (
-
-
- - - -
-
{prompt}
-
- {ratio} - · - {count} 张 - · - {STYLE_OPTIONS.find((s) => s.id === style)?.label || "默认"} + {/* 行31/32/33:每个批次独立一条对话,各自展示自己的图与操作 */} + {batches.map((batch) => ( +
+
+ + + +
+
{batch.prompt}
+
+ {batch.ratio} + · + {batch.count} 张 + · + {batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"} +
+
+
+
{renderBatchGrid(batch)}
+
+ + +
+ +
+ + +
-
{renderResultGrid()}
-
- - - -
-
+ ))} + setPreview(null)} />
) : (
@@ -752,12 +999,19 @@ export function ImageWorkbenchPage({ 预估 ¥{(candidateCount * 0.1).toFixed(2)}
+ } + close={() => setAdoptNotice(null)} + />
); } @@ -990,17 +1244,18 @@ export function ImageWorkbenchPage({
+ {/* 行31:并发提交——有批次在跑时仍可再次提交,只在提交瞬间不重复触发 */} -
// 采用即扣费并入对应商品 AI 素材 · 未采用不扣
+
// 采用即扣费并入对应商品 AI 素材 · 未采用不扣{anyGenerating ? " · 有批次生成中,可继续提交" : ""}
- {/* 右 · 结果预览 */} + {/* 右 · 结果预览(行31/32/33:多批次列表) */}
- {!hasResults && !generating ? ( + {!hasResults ? (
// EMPTY STATE
还没有生成结果
@@ -1013,7 +1268,7 @@ export function ImageWorkbenchPage({
- {count} 张 + {batches.length} 批 {mode === "model" ? ` · ${ratio}` : ""}
@@ -1033,26 +1288,20 @@ export function ImageWorkbenchPage({
)}
-
{renderResultGrid()}
-
- - - -
+ {renderBatchList()} )}
+ } + close={() => setAdoptNotice(null)} + /> ); } diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 8e5e58a..7c802ca 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -1,13 +1,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react"; import { createPortal } from "react-dom"; -import { ArrowLeft, X } from "lucide-react"; +import { ArrowLeft, Trash2, X } from "lucide-react"; import { ConfirmModal, MediaLightbox, SuccessModal, useBodyScrollLock } from "../components/overlays"; import { Pager } from "../components/pager"; const PROD_PAGE_SIZE = 10; import type { Asset, Product, Project } from "../types"; -import type { Page } from "./route-config"; +import type { NavigateFn, Page } from "./route-config"; import "../product-create-page.css"; const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"]; @@ -63,6 +63,9 @@ export function ProductsPage({ products, projects = [], assets = [], navigate, o const [editMode, setEditMode] = useState(false); const [selected, setSelected] = useState>(new Set()); const [confirmIds, setConfirmIds] = useState(null); + // 删除乐观更新:确认即把目标 id 收进 deletingIds → 卡片立刻从网格隐藏(体感即时), + // 同时锁住按钮防重复点击;待 onDelete resolve 后由父组件刷新 products 真正移除。 + const [deletingIds, setDeletingIds] = useState>(new Set()); useEffect(() => { document.body.classList.toggle("edit-mode", editMode); return () => document.body.classList.remove("edit-mode"); @@ -76,8 +79,17 @@ export function ProductsPage({ products, projects = [], assets = [], navigate, o const doDelete = async () => { const ids = confirmIds || []; setConfirmIds(null); - for (const id of ids) await onDelete?.(id); + if (!ids.length) return; + // 乐观:立刻隐藏 + 清空选择,用户马上看到卡片消失,不会再误点同一张 + setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; }); setSelected(new Set()); + try { + // 并发删除,缩短多选批删的体感时延(不再串行逐个等待) + await Promise.all(ids.map((id) => onDelete?.(id))); + } finally { + // 父组件刷新后这些 id 已不在 products 里;清理本地标记避免泄漏 + setDeletingIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.delete(id)); return next; }); + } }; const [drawer, setDrawer] = useState(Boolean(autoOpenCreate)); useBodyScrollLock(drawer); @@ -129,6 +141,7 @@ export function ProductsPage({ products, projects = [], assets = [], navigate, o }, [openChip]); const filtered = products.filter((product) => { + if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除 const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()); const matchCat = !catFilter || product.category === catFilter; let matchDate = true; @@ -297,6 +310,8 @@ export function ProductsPage({ products, projects = [], assets = [], navigate, o } detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?该操作不可撤销,商品的图册与关联记录也会一并移除。`} confirmText="删除" onCancel={() => setConfirmIds(null)} @@ -644,25 +659,33 @@ function pdProjStatusLabel(project: Project) { } function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; } -export function ProductDetailPage({ product, projects, assets, initialTab = "assets", navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages }: { +export function ProductDetailPage({ product, projects, assets, initialTab = "assets", navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages, onAdoptTriView }: { product: Product; projects: Project[]; assets: Asset[]; initialTab?: "assets" | "videos"; - navigate: (page: Page) => void; + navigate: NavigateFn; onUpdate: (payload: Partial) => Promise | void; onUploadImage?: (formData: FormData) => Promise | void; onDeleteImage?: (imageId: string) => Promise | void; onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>; + // 采用三视图版本:把该版本的 asset 作为商品图持久化进 AI 素材库(由 App.tsx 接线;不传则仅本地标记) + onAdoptTriView?: (asset: Asset) => Promise | void; }) { const [tab, setTab] = useState<"assets" | "videos">(initialTab); const [editing, setEditing] = useState(false); const [triOpen, setTriOpen] = useState(false); const imgInputRef = useRef(null); const [uploading, setUploading] = useState(false); - // 三视图生成 + // 三视图生成 · 单张 16:9 图;每次生成进版本历史,可切换查看 + 采用 const [triGenerating, setTriGenerating] = useState(false); - const [triUrl, setTriUrl] = useState(""); + type TriVersion = { id: string; url: string; asset?: Asset; createdAt: string }; + const [triVersions, setTriVersions] = useState([]); + // 当前查看的版本 id(默认看最新);采用的版本 id(采用后才进 AI 素材库) + const [triViewingId, setTriViewingId] = useState(""); + const [triAdoptedId, setTriAdoptedId] = useState(""); + const triViewing = triVersions.find((v) => v.id === triViewingId) || triVersions[0]; + const triUrl = triViewing?.url || ""; // 素材 tab 筛选 / 排序 / 分页 const [openFilter, setOpenFilter] = useState<"" | "type" | "sort">(""); const [typeFilter, setTypeFilter] = useState(""); @@ -700,17 +723,30 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass if (!onGenerateImages || triGenerating) return; setTriGenerating(true); try { + // 三视图 = 单张 16:9 横图(正面/侧面/背面并排),不拆成 3 张 const res = await onGenerateImages({ - prompt: `${product.title || "商品"} 白底商品三视图(正面/侧面/背面),电商主图,干净白底,高清`, + prompt: `${product.title || "商品"} 白底商品三视图,正面 / 侧面 / 背面三视图并排于同一张 16:9 横图,电商主图,干净白底,高清`, mode: "image", count: 1 }); - const url = res?.assets?.[0]?.files?.[0]?.preview_url; - if (url) setTriUrl(url); + const asset = res?.assets?.[0]; + const url = asset?.files?.[0]?.preview_url; + if (url) { + const version: TriVersion = { id: asset?.id || `tri-${Date.now()}`, url, asset, createdAt: asset?.created_at || new Date().toISOString() }; + // 新版本置顶进历史并切到该版本查看(采用需手动点「采用」) + setTriVersions((list) => [version, ...list]); + setTriViewingId(version.id); + } } finally { setTriGenerating(false); } } + // 采用当前查看版本:标记为已采用 → 由该版本三视图作为商品图进 AI 素材库 + function adoptTriView() { + if (!triViewing) return; + setTriAdoptedId(triViewing.id); + if (triViewing.asset && onAdoptTriView) void onAdoptTriView(triViewing.asset); + } // 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重) const assetById = new Map(assets.map((asset) => [asset.id, asset])); @@ -781,29 +817,60 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass 商品信息 {/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
+ {/* icon 规范化:用「立方体三视」线性图标,替代原星星簇,更贴合三视图语义 */} @@ -903,15 +970,15 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
// 图片生成
-
navigate("modelPhoto")}> +
navigate("modelPhoto", { productId: product.id })}> 模特上身图
-
navigate("platformCover")}> +
navigate("platformCover", { productId: product.id })}> 平台套图
-
navigate("imageOptimize")}> +
navigate("imageOptimize", { productId: product.id })}> 图片创作
@@ -920,7 +987,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
// 视频生成
-
navigate("projectWizard")}> +
navigate("projectWizard", { productId: product.id })}> 生成视频
@@ -977,6 +1044,16 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
+ {assetCount === 0 ? ( +
+
+ +
+

{typeFilter ? "该类型暂无 AI 素材" : "还没有 AI 素材"}

+

// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图

+ +
+ ) : (
{imageAssets.map((asset) => { const url = pdAssetPreview(asset); @@ -993,6 +1070,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass ); })}
+ )} {imageAssets.length < filteredAssets.length && (
diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index 1b7c8ad..5540ad1 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { CSSProperties, FormEvent } from "react"; import type { Product, Project } from "../types"; import type { Page } from "./route-config"; -import { ConfirmModal, EmptyPanel } from "../components/overlays"; +import { ConfirmModal, Drawer, EmptyPanel } from "../components/overlays"; import { Pager } from "../components/pager"; import "../project-wizard-page.css"; @@ -11,34 +11,38 @@ const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数 // 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择, // 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。 const WIZ_DURATIONS = [ - { id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "黄金完播" }, - { id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "完播率最佳" }, - { id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点详解" }, - { id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化" } -]; -const WIZ_STYLES = [ - { id: "pain", name: "痛点种草", note: "用户痛点切入,以「我懂你」的口吻引出产品。", tag: "最常用" }, - { id: "review", name: "开箱测评", note: "朋友式分享,从开箱到使用感受娓娓道来。", tag: "" }, - { id: "compare", name: "对比展示", note: "「用前 vs 用后 / 同类 vs 本品」直观呈现。", tag: "" } -]; -const WIZ_PERSONAS = [ - { id: "urban", name: "都市白领女性", sub: "25-30 岁", metric: "大盘消费力", dur: "0-15", style: "pain" }, - { id: "bestie", name: "闺蜜种草", sub: "邻家女孩", metric: "复购最高", dur: "0-15", style: "pain" }, - { id: "ceo", name: "总裁亲选", sub: "创始人 IP", metric: "30 万销额案例", dur: "0-30", style: "pain" }, - { id: "reviewer", name: "专业测评师", sub: "垂类达人", metric: "互动 +30%", dur: "0-30", style: "review" }, - { id: "mom", name: "实用宝妈", sub: "家庭决策者", metric: "母婴/家清稳", dur: "0-30", style: "pain" }, - { id: "genz", name: "学生党", sub: "Z 世代 18-24", metric: "平价快消", dur: "0-10", style: "compare" } + { id: "0-10", label: "0-10 秒", shots: [3, 4], tag: "快速种草", note: "适合快速种草" }, + { id: "0-15", label: "0-15 秒", shots: [4, 5], tag: "精准短片", note: "适合短平快投放" }, + { id: "0-30", label: "0-30 秒", shots: [6, 8], tag: "卖点展开", note: "适合卖点展开" }, + { id: "0-60", label: "0-60 秒", shots: [10, 12], tag: "故事化", note: "适合故事化表达" } ]; const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品 +// 新建商品抽屉品类选项(与 products.tsx 一致;本组件自洽,不依赖 products.tsx) +const WIZ_PC_CATS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; -export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: { +type WizProductPayload = { + title: string; + category: string; + target_audience?: string; + selling_points?: Array<{ title: string; detail: string; sort_order: number }>; +}; + +export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct }: { products: Product[]; projects?: Project[]; + // 从商品详情页「立即生成」进入时,预勾选该商品(而非默认第一个/耳机);id 不存在则回落默认。 + preselectProductId?: string; onBack: () => void; onCreate: (payload: { name: string; product: string; metadata?: Record }) => Promise | void; + // 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。 + onCreateProduct?: (payload: WizProductPayload) => Promise | void; }) { - const [productId, setProductId] = useState(products[0]?.id || ""); + // 预选 id 命中商品库时用它,否则默认第一个商品 + const initialProductId = + (preselectProductId && products.some((p) => p.id === preselectProductId) && preselectProductId) || + products[0]?.id || ""; + const [productId, setProductId] = useState(initialProductId); const product = products.find((item) => item.id === productId) || products[0]; // 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1); // 用户手动改过名字后不再覆盖。 @@ -52,25 +56,40 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: const [pickView, setPickView] = useState<"grid" | "list">("grid"); const [pickPage, setPickPage] = useState(1); - // Step 2 · 配置(视觉选择,详见 Stage 1) + // 新建商品 · 右侧抽屉本地态(自洽实现,不依赖 products.tsx) + const [npOpen, setNpOpen] = useState(false); + const [npTitle, setNpTitle] = useState(""); + const [npCat, setNpCat] = useState(WIZ_PC_CATS[0]); + const [npAudience, setNpAudience] = useState(""); + const [npPoints, setNpPoints] = useState([]); + const [npDraft, setNpDraft] = useState(""); + const [npTitleError, setNpTitleError] = useState(false); + const [npSaving, setNpSaving] = useState(false); + + // Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手) const [duration, setDuration] = useState("0-15"); - const [scriptStyle, setScriptStyle] = useState("pain"); - const [persona, setPersona] = useState(null); - const [recoDismissed, setRecoDismissed] = useState(false); const [points, setPoints] = useState>({}); useEffect(() => { if (!productId && products[0]) setProductId(products[0].id); }, [productId, products]); + // 卖点胶囊跟随当前选中商品初始化(默认全不勾选)。初始挂载时(含预选商品)即填充, + // 修复「默认选中商品却不显示卖点,要切走再切回才出现」的初始化时机 bug。 + useEffect(() => { + const p = products.find((item) => item.id === productId); + const seed: Record = {}; + (p?.selling_points || []).forEach((sp) => { seed[sp.title] = false; }); + setPoints(seed); + }, [productId, products]); + useEffect(() => { if (nameTouched) return; - const styleName = WIZ_STYLES.find((s) => s.id === scriptStyle)?.name || "痛点种草"; - const base = `${(product?.title || "商品").split(" ")[0]} · ${styleName}`; + const base = `${(product?.title || "商品").split(" ")[0]} · 短视频`; let version = 1; while (projects.some((p) => p.name === `${base} · v${version}`)) version += 1; setName(`${base} · v${version}`); - }, [nameTouched, product, scriptStyle, projects]); + }, [nameTouched, product, projects]); // 分类清单 const cats = useMemo( @@ -99,13 +118,8 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: const pageList = filtered.slice((cur - 1) * WIZ_PAGE_SIZE, cur * WIZ_PAGE_SIZE); function selectProduct(id: string) { + // 卖点种子由 productId useEffect 统一初始化,这里只切换选中商品 setProductId(id); - const p = products.find((item) => item.id === id); - if (p) { - const seed: Record = {}; - (p.selling_points || []).forEach((sp) => { seed[sp.title] = false; }); - setPoints(seed); - } } function clearPickFilters() { @@ -114,25 +128,52 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: setPickPage(1); } - function applyPreset() { - const p = WIZ_PERSONAS.find((item) => item.id === persona); - if (!p) return; - setDuration(p.dur); - setScriptStyle(p.style); - setRecoDismissed(false); + // 点击「创建新商品」:有 onCreateProduct 接线则开右侧抽屉,否则回落到旧行为(返回去商品库) + function openCreateProduct() { + if (!onCreateProduct) { onBack(); return; } + setNpTitle(""); + setNpCat(WIZ_PC_CATS[0]); + setNpAudience(""); + setNpPoints([]); + setNpDraft(""); + setNpTitleError(false); + setNpOpen(true); + } + + function addNpPoint() { + const v = npDraft.trim(); + if (!v || npPoints.includes(v)) { setNpDraft(""); return; } + setNpPoints((prev) => [...prev, v]); + setNpDraft(""); + } + + async function submitNewProduct() { + if (!onCreateProduct) return; + const name = npTitle.trim(); + if (!name) { setNpTitleError(true); return; } // 空名:必填校验,不静默无反应 + setNpSaving(true); + try { + const created = await onCreateProduct({ + title: name, + category: npCat, + target_audience: npAudience.trim() || undefined, + selling_points: npPoints.map((item, index) => ({ title: item, detail: item, sort_order: index })) + }); + setNpOpen(false); + // 新建成功且拿到 id → 自动选中该商品进入第 1 步选中态 + if (created && typeof created === "object" && "id" in created) { + setProductId((created as Product).id); + } + } finally { + setNpSaving(false); + } } - const personaObj = WIZ_PERSONAS.find((p) => p.id === persona); const durObj = WIZ_DURATIONS.find((d) => d.id === duration); - const styleObj = WIZ_STYLES.find((s) => s.id === scriptStyle); - const showReco = - !!personaObj && !!duration && !!scriptStyle && !recoDismissed && - (personaObj.dur !== duration || personaObj.style !== scriptStyle); - const recoDur = personaObj && WIZ_DURATIONS.find((d) => d.id === personaObj.dur); - const recoStyle = personaObj && WIZ_STYLES.find((s) => s.id === personaObj.style); const product1Done = !!productId; - const config2Done = !!duration && !!styleObj && name.trim().length >= 2; + // 信息没填完(项目名 < 2 字 / 未选时长)时「开始」禁用 + const config2Done = !!durObj && name.trim().length >= 2; const canStart = product1Done && config2Done; // 提交中状态:创建项目后 App 端全量刷新需数秒,按钮要给「创建中…」反馈,避免用户以为没点上 @@ -140,7 +181,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: async function submit(event: FormEvent) { event.preventDefault(); if (!canStart || !product || starting) return; - // 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用 + // 向导选项(成片时长档 / 选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用 const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id); setStarting(true); try { @@ -150,8 +191,6 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: metadata: { wizard: { duration, - script_style: scriptStyle, - persona, selling_point_ids: selectedPoints } } @@ -196,7 +235,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: ) : "2"}
项目配置
-
{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}
+
{durObj ? `${durObj.label} · ${name.trim() || "未命名"}` : "项目名 · 成片时长"}
@@ -241,7 +280,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}
-
+
创建新商品
// 在此添加一个新商品
@@ -278,10 +317,10 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
- 找不到想要的商品?可创建新商品,或前往 商品库 · 管理商品 + 找不到想要的商品?可创建新商品,或前往 商品库 · 管理商品
- {products.length === 0 && } + {products.length === 0 && }
@@ -290,67 +329,28 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:

第 2 步 · 项目配置

-

这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。

-
- -
-
- - { setName(event.target.value); setNameTouched(true); }} /> -
-
- - -
+

基础配置只保留项目名和成片时长。脚本来源、风格和人物设定已移到流水线第 1 步由脚本助手引导。

- + + { setName(event.target.value); setNameTouched(true); }} /> +
+ +
+
- {WIZ_STYLES.map((s) => ( -
setScriptStyle(s.id)}> -

{s.name}

-
{s.note}
- {s.tag && [ {s.tag} ]} + {WIZ_DURATIONS.map((d) => ( +
setDuration(d.id)}> + [ {d.tag} ] +

{d.label}

+
{d.shots[0]}-{d.shots[1]} 镜 · 9:16
+
{d.note}
))}
-
- -
- {WIZ_PERSONAS.map((p) => ( -
{ setPersona(p.id); setRecoDismissed(false); }}> -

{p.name}

-
{p.sub}
-
{p.metric}
-
- ))} -
- - {showReco && recoDur && recoStyle && durObj && styleObj && ( -
- - - -
- 抖音同人设 TOP 视频更常用 {recoDur.label} + {recoStyle.name} - 当前 {durObj.label} · {styleObj.name} → 推荐换为同人设最优组合 -
- - -
- )} -
- {Object.keys(points).length > 0 && (
@@ -376,6 +376,57 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
+ + {/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */} + setNpOpen(false)}> +
+ + { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }} + /> + {npTitleError &&
// 商品名称不能为空
} +
+
+ + +
+
+ + setNpAudience(event.target.value)} /> +
+
+ +
+ setNpDraft(event.target.value)} + onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }} + /> + +
+ {npPoints.length > 0 && ( +
+ {npPoints.map((p) => ( + + ))} +
+ )} +
+
+ + +
+
); } @@ -609,7 +660,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
{productTitle(project.product)} - AI 全生 + {(project.script_versions?.length || 0) > 0 ? "AI 已生成" : "暂无脚本"}
{[1, 2, 3, 4, 5].map((i) => )}