完善二期清单
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user