fix(core): 测试清单 行16-33 — 商品详情/视频项目/新建向导/图片生成 批量修复
商品库+详情(行16-23):AI素材空状态缺省页;三视图16:9+版本历史切换+采用按钮; 详情「生成视频」预勾选商品、快捷生成图带入商品;管理商品编辑态保留底部统计; 删除商品 icon+DELETE ITEM 文案、乐观删除防重复点击;卡片图铺满占位区。 视频项目+新建向导(行24-28):筛选脚本来源按真实数据渲染;新建项目内「创建新商品」 右侧抽屉+创建后自动选中;开始栏 sticky 常驻;默认选中商品即显示卖点、未填完禁用开始; 去掉脚本风格/人物设定区块。 图片生成工作台(行29-33):模特单选;生成中改 spinner 动态图标+按钮间距;多任务并行; 重跑追加批次而非覆盖+更多按钮 hover 气泡菜单+「加入资产库/取消」切换与提示弹窗; 多批次本地持久化恢复。 收尾:App.tsx 接 initialProductId/preselectProductId/onCreateProduct/onAdoptTriView; 全局 toast 自动消失(成功3s/错误5s)修复行17③/22「右上角提示一直挂着」。 tsc -b && vite build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,13 @@ export function App() {
|
||||
const [notice, setNotice] = useState<Notice>(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() {
|
||||
<ProjectWizardPage
|
||||
products={products}
|
||||
projects={projects}
|
||||
preselectProductId={activeProductId}
|
||||
onBack={() => 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 <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
|
||||
case "imageOptimize":
|
||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "modelPhoto":
|
||||
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "platformCover":
|
||||
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
|
||||
case "modelPhotoDemoA":
|
||||
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
|
||||
case "modelPhotoDemoB":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<unknown>;
|
||||
}) {
|
||||
@@ -110,7 +112,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
|
||||
<div className="modal-bg show" onClick={onCancel}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m"><Shield size={16} /></div><div className="ti">{title}<span>// CONFIRM</span></div></div>
|
||||
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}<span>{subtitle}</span></div></div>
|
||||
<div className="modal-b">{detail}</div>
|
||||
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
||||
</div>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [results, setResults] = useState<Asset[] | null>(null);
|
||||
// 批次列表:每次生成/重跑追加一条,各自展示;可多批并行 generating
|
||||
const [batches, setBatches] = useState<GenBatch[]>([]);
|
||||
// 加入资产库成功提示(行32④)
|
||||
const [adoptNotice, setAdoptNotice] = useState<{ count: number } | null>(null);
|
||||
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
|
||||
const refInputRef = useRef<HTMLInputElement | null>(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 (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
<div
|
||||
className="gen-images"
|
||||
style={{ "--cols": cols, "--ratio": ratioVar } as React.CSSProperties}
|
||||
style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties}
|
||||
>
|
||||
{(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 }) => (
|
||||
<div className={`gen-image ${generating && !url ? "gen" : ""}`} key={key}>
|
||||
{url ? (
|
||||
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" title="点击放大" style={{ cursor: "zoom-in" }} onClick={() => setPreview({ src: url, name: `${meta.title} #${index + 1}` })} />
|
||||
) : (
|
||||
<div className="placeholder">
|
||||
<span className="ph-frame">
|
||||
{generating ? "生成中…" : `${ratio} · #${index + 1}`}
|
||||
</span>
|
||||
{generating ? (
|
||||
// 行30:生成中占位用旋转 spinner icon,不再是「生成中…」文字
|
||||
<span className="gen-loading" aria-label="生成中">
|
||||
<span className="spinner" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ph-frame">{batch.ratio} · #{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="重跑" disabled={generating} onClick={() => runGenerate()}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{url && (
|
||||
{url && (
|
||||
<div className="gen-image-actions">
|
||||
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
)}
|
||||
{url && (
|
||||
<button className="gen-img-btn" type="button" title="查看原图" onClick={() => window.open(url, "_blank", "noopener")}>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 行32③:hover「更多」出气泡菜单(加入资产库 / 删除),不再直接下载 */}
|
||||
<div className="gen-img-more">
|
||||
<button className="gen-img-btn" type="button" title="更多">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
<div className="gen-bubble">
|
||||
<button type="button" className="gen-bubble-item" onClick={() => toggleAdoptImage(batch.id, assetId)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeImageFromBatch(batch.id, assetId)}>
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */
|
||||
function renderBatchList() {
|
||||
return (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{batches.map((batch) => {
|
||||
const generating = batch.status === "generating";
|
||||
const failed = batch.status === "failed";
|
||||
return (
|
||||
<div className="gen-card gen-batch-card" key={batch.id}>
|
||||
<div className="gen-batch-h">
|
||||
<span className="b-pic">{batch.count}×</span>
|
||||
<div className="b-meta">
|
||||
<div className="b-nm">{batch.count} 张 · {batch.ratio}</div>
|
||||
<div className="b-info">
|
||||
{generating ? "生成中" : failed ? "失败" : "已完成"}
|
||||
{batch.adopted && !generating && !failed ? " · 已加入资产库" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderBatchGrid(batch)}
|
||||
{/* 行30/32:批次底部操作行,与上方卡片拉开间距(.gen-batch-actions) */}
|
||||
<div className="gen-batch-actions">
|
||||
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => rerunBatch(batch)}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
{batch.adopted ? <X size={13} /> : <Check size={13} />}
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
{/* 行32③:批次底部「更多」气泡(删除当前批次 / 加入资产库) */}
|
||||
<div className="gen-batch-more">
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
<div className="gen-bubble gen-bubble-up">
|
||||
<button type="button" className="gen-bubble-item" disabled={generating || failed} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
|
||||
<Trash2 size={13} />
|
||||
删除当前批次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -628,7 +859,7 @@ export function ImageWorkbenchPage({
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<button className="ic-new-conv" type="button" onClick={() => { setResults(null); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
|
||||
<button className="ic-new-conv" type="button" onClick={() => { setBatches([]); persistBatches([]); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
|
||||
<Plus size={13} />
|
||||
新对话
|
||||
</button>
|
||||
@@ -656,37 +887,53 @@ export function ImageWorkbenchPage({
|
||||
<div className="ic-stream">
|
||||
{hasResults ? (
|
||||
<div className="ic-stream-inner">
|
||||
<div className="ic-msg">
|
||||
<div className="ic-msg-prompt">
|
||||
<span className="quote">
|
||||
<Quote size={13} />
|
||||
</span>
|
||||
<div className="pt">
|
||||
<div className="pt-text">{prompt}</div>
|
||||
<div className="pt-tags">
|
||||
<span className="meta-chip">{ratio}</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{count} 张</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{STYLE_OPTIONS.find((s) => s.id === style)?.label || "默认"}</span>
|
||||
{/* 行31/32/33:每个批次独立一条对话,各自展示自己的图与操作 */}
|
||||
{batches.map((batch) => (
|
||||
<div className="ic-msg" key={batch.id}>
|
||||
<div className="ic-msg-prompt">
|
||||
<span className="quote">
|
||||
<Quote size={13} />
|
||||
</span>
|
||||
<div className="pt">
|
||||
<div className="pt-text">{batch.prompt}</div>
|
||||
<div className="pt-tags">
|
||||
<span className="meta-chip">{batch.ratio}</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{batch.count} 张</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="meta-chip">{batch.status === "generating" ? "生成中" : batch.status === "failed" ? "失败" : "已完成"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gen-card">{renderBatchGrid(batch)}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button" disabled={batch.status === "generating"} onClick={() => rerunBatch(batch)}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
{batch.adopted ? <X size={13} /> : <Check size={13} />}
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<div className="gen-batch-more">
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="更多">
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
<div className="gen-bubble gen-bubble-up">
|
||||
<button type="button" className="gen-bubble-item" disabled={batch.status !== "done"} onClick={() => toggleAdoptBatch(batch.id)}>
|
||||
<Bookmark size={13} />
|
||||
{batch.adopted ? "取消加入资产库" : "加入资产库"}
|
||||
</button>
|
||||
<button type="button" className="gen-bubble-item danger" onClick={() => removeBatch(batch.id)}>
|
||||
<Trash2 size={13} />
|
||||
删除当前批次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gen-card">{renderResultGrid()}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button" onClick={() => setResults(null)}>
|
||||
<RefreshCw size={13} />
|
||||
重新编辑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button" onClick={() => navigate?.("library")}>
|
||||
<Check size={13} />
|
||||
全部加入资产库
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="下载全部" onClick={() => results?.forEach((asset, i) => { const u = asset.files?.[0]?.preview_url; if (u) downloadImage(u, `${meta.title}-${i + 1}.png`); })}>
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ic-empty">
|
||||
@@ -752,12 +999,19 @@ export function ImageWorkbenchPage({
|
||||
预估 <span className="val">¥{(candidateCount * 0.1).toFixed(2)}</span>
|
||||
</span>
|
||||
<button className="send-btn" type="button" onClick={runGenerate} disabled={!canGenerate} title="生成">
|
||||
{generating ? <span className="spinner" aria-hidden /> : <ArrowRight size={15} />}
|
||||
<ArrowRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<SuccessModal
|
||||
open={!!adoptNotice}
|
||||
title="加入资产库"
|
||||
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
|
||||
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
|
||||
close={() => setAdoptNotice(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -990,17 +1244,18 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
|
||||
<div className="iw-cta">
|
||||
{/* 行31:并发提交——有批次在跑时仍可再次提交,只在提交瞬间不重复触发 */}
|
||||
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
|
||||
{generating ? <span className="spinner" aria-hidden /> : <WandSparkles size={13} />}
|
||||
{generating ? "生成中…" : `立即生成 (预估 ¥${(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})`}
|
||||
<WandSparkles size={13} />
|
||||
立即生成 (预估 ¥{(candidateCount * (mode === "model" ? 0.3 : 0.5)).toFixed(2)})
|
||||
</button>
|
||||
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣</div>
|
||||
<div className="iw-cta-hint">// 采用即扣费并入对应商品 AI 素材 · 未采用不扣{anyGenerating ? " · 有批次生成中,可继续提交" : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右 · 结果预览 */}
|
||||
{/* 右 · 结果预览(行31/32/33:多批次列表) */}
|
||||
<div className="iw-preview">
|
||||
{!hasResults && !generating ? (
|
||||
{!hasResults ? (
|
||||
<div className="iw-pv-empty">
|
||||
<div className="mono">// EMPTY STATE</div>
|
||||
<div className="title">还没有生成结果</div>
|
||||
@@ -1013,7 +1268,7 @@ export function ImageWorkbenchPage({
|
||||
<div className="iw-pv-h">
|
||||
<Quote className="quote-icon" />
|
||||
<div className="pv-meta">
|
||||
<b>{count} 张</b>
|
||||
<b>{batches.length} 批</b>
|
||||
{mode === "model" ? ` · ${ratio}` : ""}
|
||||
</div>
|
||||
<div className="pv-line">
|
||||
@@ -1033,26 +1288,20 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="gen-card">{renderResultGrid()}</div>
|
||||
<div className="gen-card-actions">
|
||||
<button className="btn btn-sm" type="button" disabled={generating} onClick={() => runGenerate()}>
|
||||
<RefreshCw size={13} />
|
||||
重跑
|
||||
</button>
|
||||
<button className="btn btn-sm" type="button" onClick={() => navigate?.("library")}>
|
||||
<Check size={13} />
|
||||
采用并入资产库
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" type="button" title="下载全部" onClick={() => results?.forEach((asset, i) => { const u = asset.files?.[0]?.preview_url; if (u) downloadImage(u, `${meta.title}-${i + 1}.png`); })}>
|
||||
<MoreHorizontal size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{renderBatchList()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SuccessModal
|
||||
open={!!adoptNotice}
|
||||
title="加入资产库"
|
||||
detail={`${adoptNotice?.count ?? 0} 张图片已成功加入资产库`}
|
||||
actions={<><button className="btn" type="button" onClick={() => setAdoptNotice(null)}>知道了</button><button className="btn btn-primary" type="button" onClick={() => { setAdoptNotice(null); navigate?.("library"); }}>去资产库</button></>}
|
||||
close={() => setAdoptNotice(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(new Set());
|
||||
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
|
||||
// 删除乐观更新:确认即把目标 id 收进 deletingIds → 卡片立刻从网格隐藏(体感即时),
|
||||
// 同时锁住按钮防重复点击;待 onDelete resolve 后由父组件刷新 products 真正移除。
|
||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(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
|
||||
<ConfirmModal
|
||||
open={Boolean(confirmIds && confirmIds.length)}
|
||||
title="删除商品"
|
||||
subtitle="// DELETE ITEM"
|
||||
icon={<Trash2 size={16} />}
|
||||
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<Product>) => Promise<unknown> | void;
|
||||
onUploadImage?: (formData: FormData) => Promise<unknown> | void;
|
||||
onDeleteImage?: (imageId: string) => Promise<unknown> | void;
|
||||
onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
|
||||
// 采用三视图版本:把该版本的 asset 作为商品图持久化进 AI 素材库(由 App.tsx 接线;不传则仅本地标记)
|
||||
onAdoptTriView?: (asset: Asset) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"assets" | "videos">(initialTab);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [triOpen, setTriOpen] = useState(false);
|
||||
const imgInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
// 三视图生成
|
||||
// 三视图生成 · 单张 16:9 图;每次生成进版本历史,可切换查看 + 采用
|
||||
const [triGenerating, setTriGenerating] = useState(false);
|
||||
const [triUrl, setTriUrl] = useState<string>("");
|
||||
type TriVersion = { id: string; url: string; asset?: Asset; createdAt: string };
|
||||
const [triVersions, setTriVersions] = useState<TriVersion[]>([]);
|
||||
// 当前查看的版本 id(默认看最新);采用的版本 id(采用后才进 AI 素材库)
|
||||
const [triViewingId, setTriViewingId] = useState<string>("");
|
||||
const [triAdoptedId, setTriAdoptedId] = useState<string>("");
|
||||
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
|
||||
<span className="ti">商品信息</span>
|
||||
{/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
|
||||
<div className="ov-tri-wrap">
|
||||
{/* icon 规范化:用「立方体三视」线性图标,替代原星星簇,更贴合三视图语义 */}
|
||||
<button className={`ov-edit ov-tri-trigger${triOpen ? " is-open" : ""}`} type="button" id="ov-tri-btn" title="AI 生成商品三视图" aria-haspopup="dialog" aria-expanded={triOpen} onClick={() => setTriOpen((value) => !value)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /><path d="M19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9L19 14z" /></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2.5l8 4.5v9l-8 4.5-8-4.5v-9l8-4.5Z" /><path d="M12 11.5l8-4.5M12 11.5v9.5M12 11.5L4 7" /></svg>
|
||||
AI 生成三视图
|
||||
</button>
|
||||
<div className={`ov-tri-pop${triOpen ? " show" : ""}`} id="ov-tri-pop" role="dialog" aria-label="AI 生成三视图">
|
||||
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}</span></div>
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triAdoptedId && triViewing?.id === triAdoptedId ? "已采用" : triUrl ? "已生成" : "待生成"}</span></div>
|
||||
{/* 单张 16:9 三视图(.prod-preview-img 已 aspect-ratio:16/9) */}
|
||||
<div className="placeholder prod-preview-img" id="ov-tri-img" role={triUrl ? "button" : undefined} tabIndex={triUrl ? 0 : undefined} title={triUrl ? "点击放大" : undefined} style={triUrl ? { cursor: "zoom-in" } : undefined} onClick={triUrl ? () => setPreview({ src: triUrl, name: "三视图" }) : undefined} onKeyDown={triUrl ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: triUrl, name: "三视图" }); } } : undefined}>
|
||||
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
|
||||
</div>
|
||||
<div className="prod-preview-foot" id="ov-tri-foot">
|
||||
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }} onClick={generateTriView} disabled={triGenerating || !onGenerateImages}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
|
||||
{triGenerating ? "生成中…" : "生成"}
|
||||
{triGenerating ? "生成中…" : triVersions.length ? "重新生成" : "生成"}
|
||||
</button>
|
||||
{/* 采用:点后该版本三视图作为商品图进 AI 素材库 */}
|
||||
{triViewing && (
|
||||
<button className="ov-edit" type="button" id="ov-tri-adopt" style={{ height: "28px" }} onClick={adoptTriView} disabled={triViewing.id === triAdoptedId}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
||||
{triViewing.id === triAdoptedId ? "已采用" : "采用"}
|
||||
</button>
|
||||
)}
|
||||
<span style={{ flex: 1 }}></span>
|
||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-56)" }}>~¥0.30 / 次</span>
|
||||
</div>
|
||||
<div className="prod-preview-history" id="ov-tri-history">
|
||||
<div className="h-lbl">// 历史版本 · <span className="ct" id="ov-tri-history-count">0</span> 版</div>
|
||||
<div className="h-row" id="ov-tri-history-row"></div>
|
||||
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
|
||||
<div className={`prod-preview-history${triVersions.length ? " show" : ""}`} id="ov-tri-history">
|
||||
<div className="h-lbl">// 历史版本 · <span className="ct" id="ov-tri-history-count">{triVersions.length}</span> 版</div>
|
||||
<div className="h-row" id="ov-tri-history-row">
|
||||
{triVersions.map((version, index) => {
|
||||
const isViewing = version.id === triViewing?.id;
|
||||
const isAdopted = version.id === triAdoptedId;
|
||||
const verLabel = `v${triVersions.length - index}`;
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
className={`h-thumb${isAdopted ? " adopted" : isViewing ? " previewing" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={`查看 ${verLabel}`}
|
||||
onClick={() => setTriViewingId(version.id)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setTriViewingId(version.id); } }}
|
||||
style={{ backgroundImage: `url(${version.url})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
>
|
||||
<span className="badge">已采用</span>
|
||||
<span className="v">{verLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,15 +970,15 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">// 图片生成</div>
|
||||
<div className="qa-row-3">
|
||||
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto")}>
|
||||
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a4 4 0 014-4h8a4 4 0 014 4v2" /></svg></span>
|
||||
模特上身图
|
||||
</div>
|
||||
<div className="qa-item" data-go="platform-cover" role="button" tabIndex={0} onClick={() => navigate("platformCover")}>
|
||||
<div className="qa-item" data-go="platform-cover" role="button" tabIndex={0} onClick={() => navigate("platformCover", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 3v18" /></svg></span>
|
||||
平台套图
|
||||
</div>
|
||||
<div className="qa-item" data-go="image-optimize" role="button" tabIndex={0} onClick={() => navigate("imageOptimize")}>
|
||||
<div className="qa-item" data-go="image-optimize" role="button" tabIndex={0} onClick={() => navigate("imageOptimize", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v18M3 12h18M5 5l14 14M5 19l14-14" /></svg></span>
|
||||
图片创作
|
||||
</div>
|
||||
@@ -920,7 +987,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
||||
<div className="qa-section">
|
||||
<div className="qa-section-h">// 视频生成</div>
|
||||
<div className="qa-row-1">
|
||||
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard")}>
|
||||
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
|
||||
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
|
||||
生成视频
|
||||
</div>
|
||||
@@ -977,6 +1044,16 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assetCount === 0 ? (
|
||||
<div className="empty-state show pd-empty-assets">
|
||||
<div className="ic-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
|
||||
</div>
|
||||
<h3>{typeFilter ? "该类型暂无 AI 素材" : "还没有 AI 素材"}</h3>
|
||||
<p>// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图</p>
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}>去生成素材</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="asset-grid">
|
||||
{imageAssets.map((asset) => {
|
||||
const url = pdAssetPreview(asset);
|
||||
@@ -993,6 +1070,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageAssets.length < filteredAssets.length && (
|
||||
<div className="pd-more"><button type="button" onClick={() => setAssetLimit((n) => n + 12)}>加载更多({filteredAssets.length - imageAssets.length})</button></div>
|
||||
|
||||
@@ -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<string, unknown> }) => Promise<unknown> | void;
|
||||
// 「创建新商品」抽屉提交时调用;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
|
||||
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | 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<string[]>([]);
|
||||
const [npDraft, setNpDraft] = useState("");
|
||||
const [npTitleError, setNpTitleError] = useState(false);
|
||||
const [npSaving, setNpSaving] = useState(false);
|
||||
|
||||
// Step 2 · 配置(基础配置只保留项目名 + 成片时长;脚本风格/人物设定已移到流水线第 1 步脚本助手)
|
||||
const [duration, setDuration] = useState<string | null>("0-15");
|
||||
const [scriptStyle, setScriptStyle] = useState<string | null>("pain");
|
||||
const [persona, setPersona] = useState<string | null>(null);
|
||||
const [recoDismissed, setRecoDismissed] = useState(false);
|
||||
const [points, setPoints] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId && products[0]) setProductId(products[0].id);
|
||||
}, [productId, products]);
|
||||
|
||||
// 卖点胶囊跟随当前选中商品初始化(默认全不勾选)。初始挂载时(含预选商品)即填充,
|
||||
// 修复「默认选中商品却不显示卖点,要切走再切回才出现」的初始化时机 bug。
|
||||
useEffect(() => {
|
||||
const p = products.find((item) => item.id === productId);
|
||||
const seed: Record<string, boolean> = {};
|
||||
(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<string, boolean> = {};
|
||||
(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"}</div>
|
||||
<div>
|
||||
<div className="label">项目配置</div>
|
||||
<div className="desc">{durObj && styleObj ? `${durObj.label} · ${styleObj.name}` : "时长 · 风格 · 人物"}</div>
|
||||
<div className="desc">{durObj ? `${durObj.label} · ${name.trim() || "未命名"}` : "项目名 · 成片时长"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -241,7 +280,7 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
|
||||
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
|
||||
|
||||
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
|
||||
<div className="pp-create-card" onClick={onBack}>
|
||||
<div className="pp-create-card" onClick={openCreateProduct}>
|
||||
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
|
||||
<div className="pc-t">创建新商品</div>
|
||||
<div className="pc-d">// 在此添加一个新商品</div>
|
||||
@@ -278,10 +317,10 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
|
||||
|
||||
<div className="pp-bottom-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
|
||||
<span>找不到想要的商品?可<a onClick={onBack}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
|
||||
<span>找不到想要的商品?可<a onClick={openCreateProduct}>创建新商品</a>,或前往 <a onClick={onBack}>商品库 · 管理商品</a></span>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={onBack} />}
|
||||
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={openCreateProduct} />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -290,67 +329,28 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
|
||||
<div className="wiz-pane">
|
||||
<div className="wiz-step-h">
|
||||
<h2>第 2 步 · 项目配置</h2>
|
||||
<p>这些设置会影响 LLM 生成脚本的方向,确认后会进入流水线第 1 步(脚本生成)。</p>
|
||||
</div>
|
||||
|
||||
<div className="config-row">
|
||||
<div className="field">
|
||||
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">视频时长<span className="req">*</span></label>
|
||||
<select className="select duration-select" value={duration || ""} onChange={(event) => setDuration(event.target.value || null)}>
|
||||
<option value="" disabled>选择时长</option>
|
||||
{WIZ_DURATIONS.map((d) => (
|
||||
<option value={d.id} key={d.id}>{d.label} · {d.shots[0]}-{d.shots[1]} 镜</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p>基础配置只保留项目名和成片时长。脚本来源、风格和人物设定已移到流水线第 1 步由脚本助手引导。</p>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">脚本风格</label>
|
||||
<label className="field-label">项目名称<span className="req">*</span></label>
|
||||
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">视频时长<span className="req">*</span></label>
|
||||
<div className="opt-row cols-4">
|
||||
{WIZ_STYLES.map((s) => (
|
||||
<div className={`opt-card${scriptStyle === s.id ? " selected" : ""}`} key={s.id} onClick={() => setScriptStyle(s.id)}>
|
||||
<h4>{s.name}</h4>
|
||||
<div className="note">{s.note}</div>
|
||||
{s.tag && <span className="badge">[ {s.tag} ]</span>}
|
||||
{WIZ_DURATIONS.map((d) => (
|
||||
<div className={`opt-card${duration === d.id ? " selected" : ""}`} key={d.id} onClick={() => setDuration(d.id)}>
|
||||
<span className="badge">[ {d.tag} ]</span>
|
||||
<h4>{d.label}</h4>
|
||||
<div className="sub">{d.shots[0]}-{d.shots[1]} 镜 · 9:16</div>
|
||||
<div className="note">{d.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">人物设定</label>
|
||||
<div className="opt-row cols-6">
|
||||
{WIZ_PERSONAS.map((p) => (
|
||||
<div className={`opt-card${persona === p.id ? " selected" : ""}`} key={p.id} onClick={() => { setPersona(p.id); setRecoDismissed(false); }}>
|
||||
<h4>{p.name}</h4>
|
||||
<div className="sub">{p.sub}</div>
|
||||
<div className="metric"><span className="val">{p.metric}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showReco && recoDur && recoStyle && durObj && styleObj && (
|
||||
<div className="reco-bubble">
|
||||
<span className="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 16v-4M12 8h.01" /></svg>
|
||||
</span>
|
||||
<div className="txt">
|
||||
<span>抖音同人设 TOP 视频更常用 <strong>{recoDur.label}</strong> + <strong>{recoStyle.name}</strong></span>
|
||||
<span className="meta">当前 {durObj.label} · {styleObj.name} → 推荐换为同人设最优组合</span>
|
||||
</div>
|
||||
<button className="btn-apply" type="button" onClick={applyPreset}>一键套用</button>
|
||||
<button className="dismiss" type="button" onClick={() => setRecoDismissed(true)} aria-label="忽略">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(points).length > 0 && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">关键卖点(可勾选要重点突出的)</label>
|
||||
@@ -376,6 +376,57 @@ export function ProjectWizardPage({ products, projects = [], onBack, onCreate }:
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 新建商品 · 右侧抽屉(复用 overlays Drawer;portal 到 body,遮罩盖住头部/侧栏) */}
|
||||
<Drawer title="新建商品" open={npOpen} close={() => setNpOpen(false)}>
|
||||
<div className="field">
|
||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||
<input
|
||||
className={`input${npTitleError ? " error" : ""}`}
|
||||
value={npTitle}
|
||||
placeholder="例:南卡 Lite Pro 蓝牙耳机"
|
||||
onChange={(event) => { setNpTitle(event.target.value); if (npTitleError) setNpTitleError(false); }}
|
||||
/>
|
||||
{npTitleError && <div className="field-hint err">// 商品名称不能为空</div>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">商品品类</label>
|
||||
<select className="select" value={npCat} onChange={(event) => setNpCat(event.target.value)}>
|
||||
{WIZ_PC_CATS.map((c) => <option value={c} key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">目标人群</label>
|
||||
<input className="input" value={npAudience} placeholder="例:25-30 岁都市白领" onChange={(event) => setNpAudience(event.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">关键卖点</label>
|
||||
<div className="np-point-add">
|
||||
<input
|
||||
className="input"
|
||||
value={npDraft}
|
||||
placeholder="输入一个卖点后回车添加"
|
||||
onChange={(event) => setNpDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addNpPoint(); } }}
|
||||
/>
|
||||
<button className="btn btn-sm" type="button" onClick={addNpPoint}>添加</button>
|
||||
</div>
|
||||
{npPoints.length > 0 && (
|
||||
<div className="theme-pill-row" style={{ marginTop: 10 }}>
|
||||
{npPoints.map((p) => (
|
||||
<button className="theme-pill active" type="button" key={p} onClick={() => setNpPoints((prev) => prev.filter((x) => x !== p))}>
|
||||
<span>{p}</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-drawer-foot">
|
||||
<button className="btn" type="button" onClick={() => setNpOpen(false)}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={npSaving} onClick={submitNewProduct}>{npSaving ? "创建中…" : "创建商品"}</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -609,7 +660,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
</div>
|
||||
</td>
|
||||
<td>{productTitle(project.product)}</td>
|
||||
<td><span className="muted">AI 全生</span></td>
|
||||
<td><span className="muted">{(project.script_versions?.length || 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
|
||||
<td>
|
||||
<div className="hstack">
|
||||
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>
|
||||
|
||||
Reference in New Issue
Block a user