完善二期清单

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
+42 -17
View File
@@ -15,6 +15,7 @@ import type {
Project,
Team,
TeamMember,
TimelineSavePayload,
User,
UserPreference
} from "./types";
@@ -127,6 +128,8 @@ export function App() {
const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来
const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
const exportingRef = useRef(false);
const [preferences, setPreferences] = useState<UserPreference | null>(null);
const [sessions, setSessions] = useState<LoginSession[]>([]);
@@ -455,6 +458,44 @@ export function App() {
if (res) setExportResult(res);
}, [activeProjectId]);
// 合成成片(把各场视频用 ffmpeg 拼成一条):**不走 action()** —— 拼接要几十秒到几分钟,
// 占住全局 loading + 单飞锁会把整条流水线按钮全禁掉(期间用户还想播放/重跑单场)。
// 进度靠 exportResult 内联展示;后端对在跑的任务会复用,连点不会拼两遍。
// payload 给定(V2 剪辑台)则先落盘编辑态,成片即所见;V1 视频阶段直接合成不传。
const submitExport = useCallback(async (payload?: TimelineSavePayload) => {
const projectId = activeProjectId;
if (!projectId || exportingRef.current) return;
exportingRef.current = true;
setNotice(null);
setExportResult({ status: "running", progress: 0, output_asset: null, output_url: "", error_message: "" });
try {
if (payload) await api.saveTimeline(projectId, payload);
await api.submitExport(projectId);
// 后端在后台线程跑 ffmpeg;这里轮询 poll-export 直到成片/失败,实时回填进度。
// 上限 ~8 分钟:后端 ffmpeg 自身 15 分钟超时,轮询窗口耗尽只是停止盯着,任务仍在跑。
for (let i = 0; i < 240; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = await api.pollExport(projectId);
setExportResult(res);
if (res.status === "succeeded") {
setNotice({ type: "success", text: "成片已合成,可直接播放" });
// 列表页的播放按钮读 final_video_url,合成完要刷一次才拿到
void loadData();
void refreshProjectDetail();
return;
}
if (res.status === "failed") throw new Error(res.error_message || "合成失败,请重试");
}
throw new Error("合成用时超出预期,后台可能仍在拼接,稍后回到本页查看");
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "合成失败" });
await refreshExport();
} finally {
exportingRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProjectId, refreshExport]);
function navigate(next: Page, options: NavigateOptions = {}) {
// 已知为子账号时在发起导航前拦截;与直接访问 URL 的 layout guard 共用同一规则。
if (role && !isOwner && isOwnerOnlyPage(next)) {
@@ -1101,23 +1142,7 @@ export function App() {
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
onSubmitExport={(payload) =>
action(async () => {
// 导出前先落盘当前编辑态(片段/字幕/转场/BGM),成片即所见
if (payload) await api.saveTimeline(pipelineProject.id, payload);
await api.submitExport(pipelineProject.id);
// 后端在后台线程跑 ffmpeg 拼接,这里轮询 poll-export 直到成片/失败,实时回填进度
for (let i = 0; i < 160; i += 1) {
const res = await api.pollExport(pipelineProject.id);
setExportResult(res);
if (res.status === "succeeded") return res;
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
// 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填)
throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看");
}, "成片已导出")
}
onSubmitExport={submitExport}
/>
);
}
+19
View File
@@ -37,6 +37,7 @@ import type {
Product,
Project,
RechargeResult,
ScriptTemplate,
ScriptVersion,
Team,
TeamMember,
@@ -258,6 +259,7 @@ export const api = {
createProduct(payload: {
title?: string;
brand?: string;
business_type?: string;
category?: string;
target_audience?: string;
description?: string;
@@ -342,6 +344,23 @@ export const api = {
{ method: "POST", body: formData }
);
},
// ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
return request<ScriptTemplate>(`/api/projects/${projectId}/save-as-template/`, {
method: "POST",
body: JSON.stringify(payload),
});
},
listScriptTemplates() {
return request<Paginated<ScriptTemplate>>("/api/script-templates/");
},
renameScriptTemplate(id: string, name: string) {
return request<ScriptTemplate>(`/api/script-templates/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
deleteScriptTemplate(id: string) {
return request<void>(`/api/script-templates/${id}/`, { method: "DELETE" });
},
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
async agentScriptStream(
@@ -4,15 +4,20 @@ import { createPortal } from "react-dom";
import { api } from "../api";
import { useBodyScrollLock, ConfirmModal } from "./overlays";
import type { Product } from "../types";
import {
BUSINESS_TYPES,
BUSINESS_TYPE_KEYS,
PC_CAT_OPTIONS,
catOptionsFor,
type BusinessType,
} from "../product-business";
import "../product-create-page.css";
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
const PF_MAX = 5;
export { PC_CAT_OPTIONS };
export type ProductCreatePayload = {
title: string;
business_type?: BusinessType;
category: string;
target_audience?: string;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
@@ -21,6 +26,9 @@ export type ProductCreatePayload = {
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
};
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
const PF_MAX = 5;
// 抽屉内主图条目:拖入即传 /assets/upload/ 进桶,assetId 是返回的素材 id;url 仅作本地缩略预览
type PfImage = { id: string; file: File; url: string; assetId?: string; status: "uploading" | "done" | "error" };
@@ -40,7 +48,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}) {
useBodyScrollLock(open);
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [businessType, setBusinessType] = useState<BusinessType>("ecommerce");
const [category, setCategory] = useState(PC_CAT_OPTIONS[0]);
const [target, setTarget] = useState("");
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
@@ -59,6 +68,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
const imagesRef = useRef<PfImage[]>([]);
imagesRef.current = images;
const isLocal = businessType === "local_life";
const activeCatOptions = isLocal ? catOptionsFor(businessType) : (catOptions.length ? catOptions : PC_CAT_OPTIONS);
function flash(title: string, sub: string) {
if (toastTimer.current) clearTimeout(toastTimer.current);
@@ -72,7 +83,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
function resetForm() {
setTitle("");
setCategory("");
setBusinessType("ecommerce");
setCategory(PC_CAT_OPTIONS[0]);
setTarget("");
setBullets([]);
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
@@ -84,7 +96,13 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
function isDirty() {
return Boolean(title.trim() || target.trim() || bullets.some((b) => b.trim()) || images.length);
return Boolean(title.trim() || businessType !== "ecommerce" || target.trim() || bullets.some((b) => b.trim()) || images.length);
}
function changeBusinessType(next: BusinessType) {
setBusinessType(next);
const nextOpts = catOptionsFor(next);
if (!nextOpts.includes(category)) setCategory(nextOpts[0]);
}
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
function guardedClose() {
@@ -166,7 +184,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
async function submit() {
const name = title.trim();
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
if (images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
// 电商必须有主图;本地生活团购是虚拟商品,允许不传图。
if (!isLocal && images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
@@ -179,11 +198,16 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
// 图已在桶里,创建时直接带上 cover_asset(主图)+ images(全部),一步落库、不再二次传图
const created = (await onCreate({
title: name,
category: category || catOptions[0],
business_type: businessType,
category: category || activeCatOptions[0],
target_audience: target.trim() || undefined,
selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index })),
cover_asset: images[0].assetId,
images: images.map((im, i) => ({ asset: im.assetId as string, sort_order: i, is_primary: i === 0 }))
...(images[0]?.assetId
? {
cover_asset: images[0].assetId,
images: images.map((im, i) => ({ asset: im.assetId as string, sort_order: i, is_primary: i === 0 })),
}
: {}),
})) || null;
if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示
close();
@@ -212,21 +236,29 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
{titleError && <div className="pc-err-note"></div>}
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={businessType} onChange={(event) => changeBusinessType(event.target.value as BusinessType)}>
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
</select>
{isLocal && <div className="pc-type-hint mono">// 团购按虚拟商品走主流程,无需实物三视图</div>}
</div>
<div className="field-row">
<div>
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
{catOptions.map((option) => <option key={option}>{option}</option>)}
{activeCatOptions.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
<div>
<label className="field-label"><span className="opt">()</span></label>
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder={isLocal ? "例: 附近上班族、带娃家庭、周末聚餐" : "例: 22-32 岁女性、敏感肌、办公室通勤"} />
</div>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<label className="field-label">{isLocal ? "门店图 / 套餐海报" : "商品主图"}{isLocal ? <span className="opt">()</span> : <span className="req">*</span>}</label>
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
<div className="pf-upload-row">
<div
@@ -243,7 +275,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</div>
<div className="uz-t"><strong></strong></div>
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
<div className="uz-d">{isLocal ? "// 店招、套餐海报均可 · 团购无实物可不传" : "// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB"}</div>
</div>
<div className="pf-example">
<div className="ex-h"></div>
@@ -252,7 +284,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
</div>
<div className="ex-d"></div>
<div className="ex-d">{isLocal ? "有门店或套餐图,后续脚本和出图更准" : "优质的商品图有助于生成更好的素材效果"}</div>
</div>
</div>
<div className="pf-grid">
@@ -301,7 +333,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
value={bullet}
onChange={(event) => updateBullet(index, event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
placeholder="例: 玻尿酸双效保湿,4 小时持久水润"
placeholder={isLocal ? "例: 双人下午茶套餐,含蛋糕+饮品,周末通用" : "例: 玻尿酸双效保湿,4 小时持久水润"}
/>
<button className="bl-x" type="button" onClick={() => removeBullet(index)} 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>
@@ -316,8 +348,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
{showGuide && (
<div className="pc-guide-note">
<strong>// 建好商品的 3 步</strong><br />
+ (,/)<br />
(800×800 ),便 AI <br />
+ (/) + <br />
;/<br />
24 ,
</div>
)}
+16
View File
@@ -344,6 +344,11 @@
.asset-card-2.prod-lib-card .prod-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; }
.asset-card-2.prod-lib-card .prod-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
.asset-card-2.prod-lib-card .prod-action { padding: 10px 12px; border-top: 1px solid var(--border-faint); background: var(--surface); }
.asset-card-2.prod-lib-card .prod-action .prod-skip-note {
font-size: 12px;
color: var(--black-alpha-56);
line-height: 1.5;
}
.asset-card-2.prod-lib-card .prod-action[hidden] { display: none; }
.asset-card-2.prod-lib-card .prod-action .btn-aigen {
width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
@@ -629,6 +634,9 @@
/* 说明行左缩进 = 标签列 64 + gap 10,跟下拉框左边缘对齐 */
.setup-card .setup-rec { font-size: 12px; line-height: 1.5; color: var(--black-alpha-40); margin: 0 0 8px 74px; }
.setup-card .setup-rec.warn { color: var(--accent-honey); }
/* 5.3 · 套用模板时的预期说明:同套路重出 ≠ 旧片换商品名 */
.setup-card .tpl-expect { font-size: 12px; line-height: 1.6; color: var(--black-alpha-56); background: var(--heat-8); border-radius: var(--r-sm); padding: 8px 10px; margin-bottom: 8px; }
.setup-card .tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 6px; }
.setup-card .setup-foot { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 4px; }
/* ── 行34 · 添加分镜:本地草稿可编辑卡片 ── */
@@ -904,3 +912,11 @@
.asset-card-del-confirm button { height: 24px; padding: 0 8px; border-radius: var(--r-sm); font-size: 11.5px; border: 1px solid var(--border-faint); cursor: pointer; }
.asset-card-del-confirm .del-yes { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.asset-card-del-confirm .del-no { background: var(--surface); color: var(--black-alpha-56); }
/* 5.1 存为模板弹窗 · 「会存进模板的内容」摊开清单 + 免责说明 */
.tpl-capture { display: flex; flex-direction: column; gap: 1px; background: var(--border-faint); border-radius: var(--r-md); overflow: hidden; box-shadow: inset 0 0 0 1px var(--border-faint); }
.tpl-capture .row { display: flex; gap: 12px; padding: 9px 12px; background: var(--background-base); font-size: 12.5px; line-height: 1.6; }
.tpl-capture .k { flex: 0 0 64px; color: var(--black-alpha-48); }
.tpl-capture .v { flex: 1; color: var(--accent-black); word-break: break-word; }
.tpl-note { margin: 10px 0 0; font-size: 12px; line-height: 1.7; color: var(--black-alpha-56); }
.tpl-note .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); margin-right: 4px; }
+29
View File
@@ -0,0 +1,29 @@
/** 商品「类目」= 电商 / 本地生活。跟商品库已有的「品类」(美妆/食品)是两个维度,不能混用。 */
export const BUSINESS_TYPES = {
ecommerce: "电商",
local_life: "本地生活",
} as const;
export type BusinessType = keyof typeof BUSINESS_TYPES;
export const BUSINESS_TYPE_KEYS = Object.keys(BUSINESS_TYPES) as BusinessType[];
/** 电商品类 · 新建抽屉 / 详情编辑共用 */
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
/** 本地生活品类 · 团购/到店核销走这条,不跟电商 SKU 混在一个下拉里 */
export const PC_LOCAL_CAT_OPTIONS = ["餐饮美食", "休闲娱乐", "丽人美发", "酒店民宿", "生活服务", "运动健身", "亲子玩乐", "团购套餐"];
export function isLocalLife(product?: { business_type?: string } | null): boolean {
return product?.business_type === "local_life";
}
export function catOptionsFor(businessType: string): string[] {
return businessType === "local_life" ? PC_LOCAL_CAT_OPTIONS : PC_CAT_OPTIONS;
}
export function businessTypeLabel(value?: string | null): string {
if (value === "local_life") return BUSINESS_TYPES.local_life;
return BUSINESS_TYPES.ecommerce;
}
@@ -305,6 +305,11 @@
.pc-drawer .form-card .field-label .opt {
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
}
.pc-drawer .pc-type-hint {
margin-top: 6px;
font-size: 12px;
color: var(--black-alpha-48);
}
.pc-drawer .form-card .input,
.pc-drawer .form-card .select {
width: 100%; height: 38px;
@@ -480,6 +480,12 @@
gap: 8px;
flex: 1 1 0; min-height: 0;
}
.ov-actions .qa-row-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
flex: 1 1 0; min-height: 0;
}
.ov-actions .qa-row-1 {
display: flex;
flex: 1 1 0; min-height: 0;
+4
View File
@@ -35,6 +35,9 @@
.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; }
.product-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.product-meta .product-cat { margin-top: 0; }
.product-meta .pill { height: 20px; font-size: 11px; }
.product-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
.product-footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-faint); font-size: 12px; color: #6f6f6f; background: var(--background-base); }
@@ -60,6 +63,7 @@
.product-grid.list-view .product-body { padding: 0; display: flex; align-items: center; gap: 14px; }
.product-grid.list-view .product-name { font-size: 14px; }
.product-grid.list-view .product-cat { margin-top: 0; }
.product-grid.list-view .product-meta { margin-top: 0; }
.product-grid.list-view .product-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 0; }
.product-grid.list-view .product-footer { border-top: 0; padding: 0; background: transparent; column-gap: 6px; }
/* 列表态卡片左上 checkbox 贴合更紧 */
@@ -308,6 +308,13 @@
.wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }
.wiz-start-bar .btn-start svg { width: 14px; height: 14px; }
/* ── 5.2/5.3 套路模板 · 选中后的预期说明块 ── */
.tpl-expect { margin-top: 10px; padding: 12px 14px; border-radius: var(--r-md); background: var(--heat-8); box-shadow: inset 0 0 0 1px var(--heat-12); }
.tpl-expect .te-head { font-size: 13px; line-height: 1.5; color: var(--accent-black); }
.tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 8px; }
.tpl-expect .te-outline { margin-top: 8px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.75; color: var(--black-alpha-56); white-space: pre-wrap; }
.tpl-expect .te-note { margin-top: 10px; font-size: 12px; line-height: 1.65; color: var(--black-alpha-56); }
/* ── 响应式:窄屏单列 ── */
@media (max-width: 1100px) {
.pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+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>
+20
View File
@@ -23,6 +23,26 @@ export const VIDEO_STRUCTURES: Record<VideoStructure, string> = {
export const PRESENTATION_KEYS = Object.keys(PRESENTATION_FORMATS) as PresentationFormat[];
export const STRUCTURE_KEYS = Object.keys(VIDEO_STRUCTURES) as VideoStructure[];
const FORMAT_KEY_BY_LABEL: Record<string, PresentationFormat> = Object.fromEntries(
(Object.entries(PRESENTATION_FORMATS) as Array<[PresentationFormat, string]>).map(([key, label]) => [label, key])
);
const STRUCTURE_KEY_BY_LABEL: Record<string, VideoStructure> = Object.fromEntries(
(Object.entries(VIDEO_STRUCTURES) as Array<[VideoStructure, string]>).map(([key, label]) => [label, key])
);
/** 脚本落库存的是中文标签(「短剧」),设定卡下拉用的是 key(drama)。两边都认。 */
export function coercePresentationFormat(value: unknown, fallback: PresentationFormat = "oral"): PresentationFormat {
if (typeof value !== "string" || !value) return fallback;
if (value in PRESENTATION_FORMATS) return value as PresentationFormat;
return FORMAT_KEY_BY_LABEL[value] || fallback;
}
export function coerceVideoStructure(value: unknown, fallback: VideoStructure = "pain"): VideoStructure {
if (typeof value !== "string" || !value) return fallback;
if (value in VIDEO_STRUCTURES) return value as VideoStructure;
return STRUCTURE_KEY_BY_LABEL[value] || fallback;
}
/** 一句话说清每个选项是什么,挂在下拉旁边当说明,别让用户猜。 */
export const PRESENTATION_HINT: Record<PresentationFormat, string> = {
oral: "一个人对镜头说,信息密度最高、最省制作",
+28
View File
@@ -245,6 +245,8 @@ export type Product = {
id: string;
title: string;
brand: string;
/** 类目:ecommerce=电商 · local_life=本地生活。跟 category(品类)是两个维度。 */
business_type?: "ecommerce" | "local_life" | string;
category: string;
target_audience: string;
specs?: Record<string, unknown>;
@@ -474,6 +476,26 @@ export type TimelineSavePayload = {
draft?: Record<string, unknown>;
};
/** 套路模板(5.1)· 只承载「怎么讲」:镜数 / 每镜作用 / 节奏 / 商品露出 / 转化写法,不含旧商品文案 */
export type ScriptTemplate = {
id: string;
name: string;
presentation_format: string;
video_structure: string;
persona: string;
total_duration: number;
outline: Array<{ index: number; role: string; duration: number; product_exposure: string; delivery: string }>;
outline_text: string;
shot_count: number;
cta: string;
scenes: string[];
source_project: string | null;
source_project_name?: string;
usage_count: number;
created_at: string;
updated_at: string;
};
export type Project = {
id: string;
name: string;
@@ -488,6 +510,8 @@ export type Project = {
cover_preview_url?: string;
script_version_count?: number;
video_segment_count?: number;
// 合成成片地址(最新一次成功拼接):列表播放按钮 / 视频阶段「播放成片」直接用它;没合成过为空串
final_video_url?: string;
stages: ProjectStage[];
script_versions: ScriptVersion[];
video_segments: VideoSegment[];
@@ -520,6 +544,10 @@ export type Project = {
presentation_format?: string;
video_structure?: string;
total_duration?: number;
// 5.2 换商品重跑:新建向导只传 template_id,name/outline 由后端按库里的模板回填
template_id?: string;
template_name?: string;
template_outline?: string;
};
cast?: string[];
scenes?: string[];