完善二期清单

This commit is contained in:
Azmat@qq.com
2026-08-18 14:11:05 +08:00
parent d1ecb52125
commit dfe94a923e
55 changed files with 1754 additions and 137 deletions
+10
View File
@@ -38,6 +38,7 @@ import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import { productMockCoverUrl } from "./products";
import { isLocalLife } from "../product-business";
import "../ai-tools-page.css";
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
@@ -666,6 +667,14 @@ export function ImageWorkbenchPage({
const [productId, setProductId] = useState(initialProductId || products[0]?.id || "");
// 选中商品同步到 App(activeProductId),保证切换栏目再回来时初始商品仍是上次选的那个
useEffect(() => { if (mode !== "image" && productId) onProductChange?.(productId); }, [mode, productId, onProductChange]);
// 本地生活没有模特上身图:进模特工作台时如果带入的是本地生活商品,改选第一个电商商品。
useEffect(() => {
if (mode !== "model") return;
const current = products.find((item) => item.id === productId);
if (!current || !isLocalLife(current)) return;
const next = products.find((item) => !isLocalLife(item));
setProductId(next?.id || "");
}, [mode, productId, products]);
// YYX#row22:选中(查看)某商品即把它的未读生成任务标记已读 → 角标清零
useEffect(() => { if (mode !== "image" && productId && (unreadByProduct?.[productId] ?? 0) > 0) onProductViewed?.(productId); }, [mode, productId, unreadByProduct, onProductViewed]);
const product = products.find((item) => item.id === productId) || products[0];
@@ -759,6 +768,7 @@ export function ImageWorkbenchPage({
// 左侧商品空间搜索(原 input 无 state/无过滤=死框,输入无效);按名称/分类过滤
const [prodQuery, setProdQuery] = useState("");
const visibleProducts = products.filter((item) => {
if (mode === "model" && isLocalLife(item)) return false;
const q = prodQuery.trim().toLowerCase();
if (!q) return true;
return `${item.title} ${item.category || ""}`.toLowerCase().includes(q);
+157 -33
View File
@@ -8,13 +8,15 @@ import { isPublicGenerationError, presentGenerationError } from "../generation-e
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
import { ModelLibrary } from "../components/model-library";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { IconKitSvg } from "../components/IconKitSvg";
import {
allowedStructures,
clampDuration,
coercePresentationFormat,
coerceVideoStructure,
DURATION_OPTIONS,
durationWarning,
isForbidden,
@@ -30,6 +32,7 @@ import {
type PresentationFormat,
type VideoStructure,
} from "../script-setup";
import { isLocalLife } from "../product-business";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
@@ -50,6 +53,15 @@ const VO_VOICES = [
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
const PERSONA_KEY_BY_LABEL: Record<string, string> = {
...Object.fromEntries(Object.entries(WIZ_PERSONA_LABEL).map(([key, label]) => [label, key])),
: "reviewer",
};
function coercePersona(value: unknown, fallback = "urban"): string {
if (typeof value !== "string" || !value) return fallback;
if (WIZ_PERSONA_LABEL[value]) return value;
return PERSONA_KEY_BY_LABEL[value] || fallback;
}
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
@@ -1276,12 +1288,12 @@ export function PipelinePage(props: {
const [setupOpen, setSetupOpen] = useState(false);
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
(wizard?.presentation_format as PresentationFormat) || recommended.format
coercePresentationFormat(wizard?.presentation_format, recommended.format)
);
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
(wizard?.video_structure as VideoStructure) || recommended.structure
coerceVideoStructure(wizard?.video_structure, recommended.structure)
);
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
const [setupPersona, setSetupPersona] = useState<string>(coercePersona(wizard?.persona, SETUP_PERSONA_KEYS[0] || "urban"));
const [setupDuration, setSetupDuration] = useState<number>(() => {
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
// 兼容一期向导存的字符串档位("0-30" → 30)
@@ -1299,6 +1311,36 @@ export function PipelinePage(props: {
setSetupStructure(recommended.structure);
if (!wizardHasDuration) setSetupDuration(recommended.duration);
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
// ── 5.2 换商品重跑 ── 新建向导选的套路模板由后端回填进 metadata.wizard,这里只读不写
const templateName = typeof wizard?.template_name === "string" ? wizard.template_name : "";
const templateOutline = typeof wizard?.template_outline === "string" ? wizard.template_outline : "";
// ── 5.1 存为模板 ── 把这一版脚本的套路存进团队模板库,换商品重开项目时套用
const [tplOpen, setTplOpen] = useState(false);
const [tplName, setTplName] = useState("");
const [tplSaving, setTplSaving] = useState(false);
function openSaveTemplate() {
const formatLabel = PRESENTATION_FORMATS[setupFormat];
const structureLabel = VIDEO_STRUCTURES[setupStructure];
setTplName(`${formatLabel} · ${structureLabel} · ${shots.length}`);
setTplOpen(true);
}
async function saveTemplate() {
const name = tplName.trim();
if (name.length < 2 || tplSaving || !currentScript) return;
setTplSaving(true);
try {
const tpl = await api.saveProjectAsTemplate(project.id, { name, script_version_id: currentScript.id });
setTplOpen(false);
onNotify?.("success", `已存为套路模板「${tpl.name}」,新建项目时可选它换商品重跑`);
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "存模板失败,请重试");
} finally {
setTplSaving(false);
}
}
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
function pickFormat(next: PresentationFormat) {
@@ -1595,18 +1637,21 @@ export function PipelinePage(props: {
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
async function runScriptWithSetup() {
const formatLabel = PRESENTATION_FORMATS[setupFormat];
const structureLabel = VIDEO_STRUCTURES[setupStructure];
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
const format = coercePresentationFormat(setupFormat);
const structure = coerceVideoStructure(setupStructure);
const persona = coercePersona(setupPersona);
const formatLabel = PRESENTATION_FORMATS[format];
const structureLabel = VIDEO_STRUCTURES[structure];
const personaLabel = WIZ_PERSONA_LABEL[persona] || persona;
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
await onSaveProjectMeta?.({
wizard: {
...(project.metadata?.wizard ?? {}),
presentation_format: setupFormat,
video_structure: setupStructure,
presentation_format: format,
video_structure: structure,
total_duration: setupDuration,
persona: setupPersona,
persona,
},
});
setSetupOpen(false);
@@ -1645,9 +1690,16 @@ export function PipelinePage(props: {
return;
}
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
// 5.2 换商品重跑:新建项目时选了套路模板 → 把模板的镜序/节奏/转化写法并进 prompt,
// 但明确要求文案按当前商品重写(模板里不含旧文案,转化写法可能带旧品牌词,这里再挡一道)。
const tplLine = templateOutline
? `\n请照这个套路重出一版:\n${templateOutline}\n`
+ `镜数、每镜作用与时长按上面来,文案全部按我当前的商品重新写,`
+ `参考写法里出现的旧商品、品牌、价格一律不要保留。`
: "";
await runScriptGeneration(
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
`${sourceLabel}:${combo}`,
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放${tplLine}`,
templateName ? `套用模板「${templateName}」:${combo}` : `${sourceLabel}:${combo}`,
"ai",
);
}
@@ -2473,13 +2525,22 @@ export function PipelinePage(props: {
return () => window.clearInterval(timer);
}, [activeVideoCount, onPollVideosQuiet]);
// Stage 5:进入拼接页时回填已有导出成片(此前导过)
// 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口)
useEffect(() => {
if (viewStage !== 5) return;
if (viewStage !== 4 && viewStage !== 5) return;
onRefreshExport();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewStage]);
// 合成中:静默轮进度。合成本身由发起方(App.submitExport)在轮,这里是为「合成中刷新了页面」
// 兜底 —— 否则发起方的轮询循环随刷新丢掉,界面会卡在旧百分比、按钮一直禁用。
const exportRunning = exportResult?.status === "queued" || exportResult?.status === "running";
useEffect(() => {
if (!exportRunning || (viewStage !== 4 && viewStage !== 5)) return;
const timer = setInterval(() => onRefreshExport(), 3000);
return () => clearInterval(timer);
}, [exportRunning, viewStage, onRefreshExport]);
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
async function confirmScript() {
if (currentScript && !scriptAdopted) {
@@ -2645,6 +2706,7 @@ export function PipelinePage(props: {
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
const productRecord = products.find((item) => item.id === project.product);
const productName = productRecord?.title || "透真补水面膜";
const isLocalProduct = isLocalLife(productRecord);
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
@@ -2795,21 +2857,14 @@ export function PipelinePage(props: {
<div className="script-brief-summary" aria-label="当前创作方向">
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-format">{(() => {
const k = wizard?.presentation_format as PresentationFormat | undefined;
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-structure">{(() => {
const k = wizard?.video_structure as VideoStructure | undefined;
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-format">{wizard?.presentation_format ? PRESENTATION_FORMATS[coercePresentationFormat(wizard.presentation_format)] : "待确认"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-structure">{wizard?.video_structure ? VIDEO_STRUCTURES[coerceVideoStructure(wizard.video_structure)] : "待确认"}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-duration">{(() => {
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
return `${scripted || setupDuration}`;
})()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona">{wizard?.persona ? (WIZ_PERSONA_LABEL[coercePersona(wizard.persona)] || wizard.persona) : "待确认"}</span></span>
</div>
{/* 34/3 · / :AI 稿;,,
线线, */}
@@ -2990,7 +3045,14 @@ export function PipelinePage(props: {
{setupOpen && (
<div className="msg ai">
<div className="bubble setup-card">
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}</div>
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : templateName ? `已套用模板「${templateName}」,套路参数已填好。` : "我会根据商品信息直接生成第一版。"}</div>
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」,免得用户以为是复制粘贴 */}
{templateOutline && setupSource === "ai" && (
<div className="tpl-expect">
<span className="mono">// 同套路重出</span>
沿,{setupProduct?.title || "当前商品"}
</div>
)}
<label className="setup-field">
<span className="sf-k"></span>
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
@@ -3134,6 +3196,7 @@ export function PipelinePage(props: {
<div className="stage-foot">
<div className="info"><span className="mono">[ {pts(10)} / · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading || !currentScript} onClick={openSaveTemplate}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg> </button>
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
@@ -3150,7 +3213,7 @@ export function PipelinePage(props: {
const adoptedTriAsset = productGroup?.adopted_asset || "";
const previewTriAsset = (triPreviewId && productVersions.includes(triPreviewId)) ? triPreviewId : adoptedTriAsset;
const hasTriView = productVersions.length > 0;
const triPanelShow = triPanelOpen || hasTriView || triGenerating;
const triPanelShow = isLocalProduct ? hasTriView : (triPanelOpen || hasTriView || triGenerating);
// 商品主图:优先用序列化器内嵌的 preview_url(全局 assets 已不再全量取,assetUrl 反查会落空),
// 依次 封面 → 主图 → 首图,最后才回退旧的全局反查。
const productAssetUrl =
@@ -3227,7 +3290,7 @@ export function PipelinePage(props: {
<div className="prod-row">
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={adoptedTriAsset || "prod-main"} id="asset-prod-card">
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
{!hasTriView && (
{!isLocalProduct && !hasTriView && (
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono"></span>
@@ -3245,16 +3308,19 @@ export function PipelinePage(props: {
</div>
<div className="prod-body">
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
<div className="prod-cat">{isLocalProduct ? "本地生活" : ""}{isLocalProduct && productRecord?.category ? " · " : ""}{productRecord?.category || (isLocalProduct ? "" : "未分类")}</div>
<div className="prod-date">{(project.created_at || "").slice(0, 10)} </div>
</div>
<div className="prod-action" id="asset-prod-action">
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
{isLocalProduct ? (
<div className="mono prod-skip-note">// 本地生活无需商品三视图,可直接进入下一步</div>
) : (
<div className="hstack">
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
</div>
)}
</div>
</div>
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
@@ -3691,6 +3757,12 @@ export function PipelinePage(props: {
: activeVideoCount > 0
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
: "待生成";
// 合成成片(ffmpeg 按场次顺序拼成一条):全部场次出片才能合成。
// 命名避开本作用域已有的 exporting/exportErr(那是「片段打包 zip 下载」)。
const allSegDone = segments.length > 0 && segDone === segments.length;
const mergedUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
const merging = exportResult?.status === "queued" || exportResult?.status === "running";
const mergeProgress = exportResult?.progress ?? 0;
return (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
@@ -3756,13 +3828,36 @@ export function PipelinePage(props: {
)}
<div className="stage-foot">
<div className="info"><span className="mono">[ {segDone} · {segTotalSec}s · · ]</span></div>
<div className="info">
<span className="mono">[ {segDone} · {segTotalSec}s · · ]</span>
{merging && <span className="mono" style={{ marginLeft: "10px", color: "var(--heat)" }}>// 合成中 {mergeProgress}%</span>}
{exportResult?.status === "failed" && (
<span className="mono" style={{ marginLeft: "10px", color: "var(--accent-crimson)" }}>// 合成失败:{exportResult.error_message || "请重试"}</span>
)}
</div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
{/* V1 雪藏拼接导出:隐藏「进入拼接」入口(代码保留,V2 恢复 false→true) */}
{false && (
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
{/* 合成成片:各场视频按顺序拼成一条完整视频(合成完才有下面的播放/下载) */}
{mergedUrl && (
<>
<button className="btn" type="button" onClick={() => setPreview({ src: mergedUrl, kind: "video", name: `${project.name} · 成片` })}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M6 4l13 8-13 8z" /></svg>
</button>
<a className="btn" href={mergedUrl} target="_blank" rel="noreferrer" download>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</a>
</>
)}
<button
className="btn btn-primary btn-lg"
type="button"
disabled={!allSegDone || merging}
title={allSegDone ? "把各场视频按顺序拼成一条完整视频(不扣积分)" : "所有场次都出片后才能合成"}
onClick={() => onSubmitExport()}
>
{merging ? <><span className="spinner btn-spin" aria-hidden="true" /> {mergeProgress}%</> : mergedUrl ? "重新合成" : "合成完整视频"}
{!merging && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>}
</button>
</div>
</div>
</section>
@@ -4552,6 +4647,35 @@ export function PipelinePage(props: {
onRefresh={() => void onRefreshProject()}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
{/* 5.1 存为模板 · 先把「会被存进去的」摊开给用户看,再让他起名,避免以为连文案一起存了 */}
<TeamModal
open={tplOpen}
title="存为套路模板"
subtitle="// SAVE TEMPLATE"
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h12a1 1 0 0 1 1 1v15l-7-4-7 4V5a1 1 0 0 1 1-1z" /></svg>}
close={() => setTplOpen(false)}
footer={<button className="btn btn-primary" type="button" disabled={tplName.trim().length < 2 || tplSaving} onClick={() => void saveTemplate()}>{tplSaving ? "保存中…" : "保存模板"}</button>}
>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={tplName} maxLength={40} onChange={(event) => setTplName(event.target.value)} placeholder="例如:口播 · 痛点前置 · 5 镜" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"></label>
<div className="tpl-capture">
<div className="row"><span className="k"></span><span className="v">{PRESENTATION_FORMATS[setupFormat]} · {VIDEO_STRUCTURES[setupStructure]}</span></div>
<div className="row"><span className="k"></span><span className="v">{shots.map((s) => s.role || "叙述").join(" → ") || "—"}</span></div>
<div className="row"><span className="k"></span><span className="v">{shots.length} · {shots.map((s) => `${s.duration_seconds}s`).join(" / ") || "—"}</span></div>
<div className="row"><span className="k"></span><span className="v">{WIZ_PERSONA_LABEL[setupPersona] || setupPersona}</span></div>
<div className="row"><span className="k"></span><span className="v">{(shots.find((s) => s.role === "CTA") || shots[shots.length - 1])?.narration || "—"}</span></div>
</div>
<p className="tpl-note">
<span className="mono">// NOTE</span> 模板只存上面这套「怎么讲」。这一版脚本里针对
{" "}{setupProduct?.title || "当前商品"}{" "} AI
</p>
</div>
</TeamModal>
</div>
);
}
+66 -11
View File
@@ -5,6 +5,13 @@ import { ArrowLeft, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
catOptionsFor,
isLocalLife,
type BusinessType,
} from "../product-business";
import { Pager } from "../components/pager";
import { SkeletonGrid } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
@@ -112,9 +119,10 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
// 创建成功弹窗:存刚创建的商品名,非空即展示「继续创建 / 去新建项目」选择
const [createdName, setCreatedName] = useState<string | null>(null);
const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
const [openChip, setOpenChip] = useState<"" | "cat" | "date" | "type">("");
// 商品分类筛选改回多选:Set 存已选分类(空 = 全部),菜单含每项计数 + chip 上橙色计数徽标
const [catFilter, setCatFilter] = useState<Set<string>>(new Set());
const [typeFilter, setTypeFilter] = useState<"all" | BusinessType>("all");
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
// 视图切换:网格 / 列表(.view-tog 黑激活;列表态卡片横排)
// ZWQ#row22:持久化 → 切走再切回商品库仍保持用户上次选的网格/列表
@@ -159,17 +167,18 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
if (deletingIds.has(product.id)) return false; // 乐观隐藏:删除中的商品立即从网格移除
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase());
const matchCat = catFilter.size === 0 || (product.category ? catFilter.has(product.category) : false);
const matchType = typeFilter === "all" || (product.business_type || "ecommerce") === typeFilter;
let matchDate = true;
if (dateFilter !== "all" && product.created_at) {
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000;
matchDate = days <= Number(dateFilter);
}
return matchQuery && matchCat && matchDate;
return matchQuery && matchCat && matchType && matchDate;
});
// 分页:每页 10 个,搜索/筛选变化回第 1 页
const [page, setPage] = useState(1);
const catFilterKey = Array.from(catFilter).sort().join("|");
useEffect(() => { setPage(1); }, [query, catFilterKey, dateFilter]);
useEffect(() => { setPage(1); }, [query, catFilterKey, dateFilter, typeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
@@ -199,6 +208,23 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter === "all" ? "类目" : BUSINESS_TYPES[typeFilter]}</span>
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${typeFilter === "all" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter("all"); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className="mi-sep" />
{BUSINESS_TYPE_KEYS.map((key) => (
<div className={`mi${typeFilter === key ? " selected" : ""}`} key={key} role="button" tabIndex={0} onClick={() => { setTypeFilter(key); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{BUSINESS_TYPES[key]}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
<button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
<span className="chip-label"></span>
@@ -231,8 +257,8 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
))}
</div>
</div>
{(query || catFilter.size > 0 || dateFilter !== "all") && (
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setOpenChip(""); }}>
{(query || catFilter.size > 0 || dateFilter !== "all" || typeFilter !== "all") && (
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
<X size={13} />
</button>
)}
@@ -335,7 +361,7 @@ export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, on
// 缺三视图:采用三视图后端会把它落成 product.cover_asset(见 App.tsx onAdoptTriView)。
// 列表数据里无法逐条反查 tri_view 素材,只能用真实字段:既无采用封面(cover_asset)、又无上传主图(images)
// → 判定该商品尚未补三视图(对齐 V1「新建商品卡显缺三视图」);一旦有封面/图则判不准,不显示(别瞎标)。
const missingTri = !product.cover_asset && assetCount === 0;
const missingTri = !isLocalLife(product) && !product.cover_asset && assetCount === 0;
return (
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
@@ -363,7 +389,10 @@ export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, on
)}
<div className="product-body">
<div className="product-name">{product.title}</div>
<div className="product-cat">{product.category || "未分类"}</div>
<div className="product-meta">
{isLocalLife(product) && <span className="pill neutral"></span>}
<div className="product-cat">{product.category || "未分类"}</div>
</div>
<div className="product-date">{(product.created_at || "").slice(0, 10)} </div>
</div>
<div className="product-footer">
@@ -559,7 +588,10 @@ function pdBaseStatus(asset: Asset): PdAssetStatus {
return "pending";
}
const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
function pdCatChoices(businessType: string, current: string): string[] {
const extra = businessType === "local_life" ? [] : ["美妆个护 / 精华液"];
return Array.from(new Set([...catOptionsFor(businessType), ...extra, ...(current ? [current] : [])]));
}
// 取一个 Asset 的预览图(优先主文件,其次首文件)
function pdAssetPreview(asset?: Asset): string {
@@ -849,11 +881,14 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
// 真实字段 · 只用商品真实数据,缺省留空(不再注入设计稿假数据,避免"没填的卖点/人群凭空冒出来")
const realName = product.title || "未命名商品";
const realCat = product.category || "";
const realType = (product.business_type === "local_life" ? "local_life" : "ecommerce") as BusinessType;
const isLocal = realType === "local_life";
const realTarget = product.target_audience || "";
const realBullets = product.selling_points.map((point) => point.title);
const [name, setName] = useState(realName);
const [cat, setCat] = useState(realCat);
const [bizType, setBizType] = useState<BusinessType>(realType);
const [target, setTarget] = useState(realTarget);
const [points, setPoints] = useState<string[]>(realBullets);
@@ -872,6 +907,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
function save() {
void onUpdate({
title: name,
business_type: bizType,
category: cat,
target_audience: target,
// 卖点整体替换(后端 present 即覆盖):发 {title,detail,sort_order} 不带 id;
@@ -884,6 +920,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
function cancel() {
setName(realName);
setCat(realCat);
setBizType(realType);
setTarget(realTarget);
setPoints(realBullets);
setEditing(false);
@@ -905,6 +942,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="ov-h">
<span className="ti"></span>
{/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
{!isLocal && (
<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)}>
@@ -963,6 +1001,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
</div>
</div>
)}
{/* view 模式: 单个 [编辑信息] */}
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={startEditing}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
@@ -992,12 +1031,26 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<input className="v-edit v-input" type="text" value={name} maxLength={100} onChange={(event) => setName(event.target.value)} />
</div>
</div>
<div className="row" data-field="type">
<div className="k"></div>
<div className="v">
<span className="v-static">{BUSINESS_TYPES[realType]}</span>
<select className="v-edit v-select" value={bizType} onChange={(event) => {
const next = event.target.value as BusinessType;
setBizType(next);
const opts = pdCatChoices(next, "");
if (!opts.includes(cat)) setCat(opts[0]);
}}>
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
</select>
</div>
</div>
<div className="row" data-field="cat">
<div className="k"></div>
<div className="v">
<span className="v-static">{realCat}</span>
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}>
{PD_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
{pdCatChoices(bizType, cat).map((option) => <option key={option}>{option}</option>)}
</select>
</div>
</div>
@@ -1108,11 +1161,13 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="ov-h"><span className="ti"></span></div>
<div className="qa-section">
<div className="qa-section-h">// 图片生成</div>
<div className="qa-row-3">
<div className={isLocal ? "qa-row-2" : "qa-row-3"}>
{!isLocal && (
<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", { 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>
@@ -1158,7 +1213,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<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> AI </h3>
<p>// 用右侧「快速操作」生成模特上身图 / 平台套图 / 商品三视图</p>
<p>// 用右侧「快速操作」生成{isLocal ? "平台套图 / 图片创作" : "模特上身图 / 平台套图 / 商品三视图"}</p>
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}></button>
</div>
) : (
+58 -15
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import type { Asset, Product, Project } from "../types";
import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import { ConfirmModal, EmptyPanel, MediaLightbox } from "../components/overlays";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager";
import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
@@ -14,13 +16,6 @@ const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
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, onUploadImage }: {
products: Product[];
projects?: Project[];
@@ -29,7 +24,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
onBack: () => void;
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
// 「创建新商品」抽屉提交时调用 → 落库到商品库;返回新建商品(含 id)以便自动选中。可选,未接线时抽屉退化为「去商品库」。
onCreateProduct?: (payload: WizProductPayload) => Promise<Product | null | undefined> | void;
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
// 新建商品的主图随商品一起持久化(与商品库新建商品同一流程)
onUploadImage?: (productId: string, formData: FormData) => Promise<unknown> | void;
}) {
@@ -57,6 +52,17 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
// Step 2 · 配置(基础配置只保留项目名;脚本风格/人物设定与时长都在流水线第 1 步统一落定)
const [points, setPoints] = useState<Record<string, boolean>>({});
// Step 2 · 5.2 换商品重跑:选一个团队套路模板,新项目沿用它的镜序与节奏(文案仍按新商品重写)。
// 只传 template_id,套路参数由后端按库里的模板回填,前后端不留两份真相。
const [templates, setTemplates] = useState<ScriptTemplate[]>([]);
const [templateId, setTemplateId] = useState("");
useEffect(() => {
void api.listScriptTemplates()
.then((page) => setTemplates(page.results || []))
.catch(() => setTemplates([]));
}, []);
const template = templates.find((t) => t.id === templateId);
useEffect(() => {
if (!productId && products[0]) setProductId(products[0].id);
}, [productId, products]);
@@ -140,7 +146,8 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
product: product.id,
metadata: {
wizard: {
selling_point_ids: selectedPoints
selling_point_ids: selectedPoints,
...(templateId ? { template_id: templateId } : {})
}
}
});
@@ -250,7 +257,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
)}
<div className="product-body">
<div className="product-name">{p.title}</div>
<div className="product-cat">{p.category || "未分类"}</div>
<div className="product-cat">{isLocalLife(p) ? `本地生活${p.category ? " · " + p.category : ""}` : (p.category || "未分类")}</div>
<div className="product-date">{(p.created_at || "").slice(0, 10)} </div>
</div>
</div>
@@ -287,7 +294,7 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
<div className="wiz-pane">
<div className="wiz-step-h">
<h2> 2 · </h2>
<p>线 1 </p>
<p>线 1 </p>
</div>
<div className="field">
@@ -295,6 +302,28 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
{/* 5.2 套路模板 · 没存过模板就整块不出现,不给空下拉占位 */}
{templates.length > 0 && (
<div className="field">
<label className="field-label">()</label>
<select className="input" value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value=""> · AI </option>
{/* 默认模板名已含镜数,选项里只补时长与使用次数,避免「4 镜 · 4 镜」 */}
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name} · {t.total_duration}s{t.usage_count ? ` · 用过 ${t.usage_count}` : ""}</option>
))}
</select>
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」 */}
{template && (
<div className="tpl-expect">
<div className="te-head"><span className="mono">// 同套路重出</span>沿用「{template.name}」的镜数、每镜作用和节奏</div>
<div className="te-outline">{template.outline_text}</div>
<div className="te-note"> {product?.title || "所选商品"} </div>
</div>
)}
</div>
)}
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">()</label>
@@ -429,6 +458,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
return next;
});
}
// 成片播放:合成过的项目点播放键直接弹窗播成片,不再跳去视频生成页
const [playing, setPlaying] = useState<Project | null>(null);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
const [catFilter, setCatFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
@@ -649,7 +680,17 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
{!editMode && (
<div className="row-action">
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
{/* 合成过成片 → 直接弹窗播成片;没合成过 → 照旧进流水线继续做 */}
<a
href="#"
title={project.final_video_url ? "播放成片" : "继续"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
}}
><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
<span className="row-more" onClick={(event) => event.stopPropagation()}>
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button></div>
@@ -702,6 +743,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<button type="button" onClick={() => setEditMode(false)}></button>
</div>
<MediaLightbox open={Boolean(playing)} src={playing?.final_video_url || ""} kind="video" name={`${playing?.name || ""} · 成片`} close={() => setPlaying(null)} />
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
</section>