feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying
Pipeline (脚本→资产→故事板→视频→拼接): - Stage1 render real script shots + wire 确认脚本→adopt (advance stage) - Stage2 add person/scene AI-生成 buttons + clickable category tabs - Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download - Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom, subtitle style + per-clip text editor, transition select (xfade preview), BGM upload + volume, save draft, export-with-save → shows/download final MP4 - embedded asset URLs everywhere (beat assets pagination) UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly disable while generating. Also includes prior uncommitted frontend work: settings preferences/sessions/avatar, asset delete, account/team/products pages, fonts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品
|
||||
export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
products: Product[];
|
||||
onBack: () => void;
|
||||
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
|
||||
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [productId, setProductId] = useState(products[0]?.id || "");
|
||||
const product = products.find((item) => item.id === productId) || products[0];
|
||||
@@ -122,7 +122,20 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!canStart || !product) return;
|
||||
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
|
||||
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
|
||||
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
|
||||
void onCreate({
|
||||
name: name.trim() || `${product.title} · 短视频`,
|
||||
product: product.id,
|
||||
metadata: {
|
||||
wizard: {
|
||||
duration,
|
||||
script_style: scriptStyle,
|
||||
persona,
|
||||
selling_point_ids: selectedPoints
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const productCover = (p: Product): CSSProperties | undefined => {
|
||||
@@ -383,8 +396,43 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
const [view, setView] = useState<"list" | "grid">("list");
|
||||
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("edit-mode", editMode);
|
||||
return () => document.body.classList.remove("edit-mode");
|
||||
}, [editMode]);
|
||||
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
|
||||
const [catFilter, setCatFilter] = useState("");
|
||||
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
|
||||
const [timeFilter, setTimeFilter] = useState<"all" | "7" | "30" | "90">("all");
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
const productCat = (id: string) => products.find((product) => product.id === id)?.category || "";
|
||||
|
||||
// 筛选选项(全部来自真实数据)
|
||||
const projectCategories = Array.from(new Set(projects.map((p) => productCat(p.product)).filter(Boolean))) as string[];
|
||||
const SRC_OPTS: Array<{ value: "all" | "has" | "none"; label: string }> = [
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "has", label: "AI 已生成脚本" },
|
||||
{ value: "none", label: "暂无脚本" }
|
||||
];
|
||||
const TIME_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "7", label: "近 7 天" },
|
||||
{ value: "30", label: "近 30 天" },
|
||||
{ value: "90", label: "近 90 天" }
|
||||
];
|
||||
const srcLabel = SRC_OPTS.find((o) => o.value === sourceFilter)?.label || "脚本来源";
|
||||
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "创建时间";
|
||||
|
||||
useEffect(() => {
|
||||
if (!openChip) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [openChip]);
|
||||
|
||||
const counts = {
|
||||
all: projects.length,
|
||||
@@ -394,7 +442,18 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
};
|
||||
const filtered = projects.filter((project) => {
|
||||
if (tab !== "all" && projBucket(project) !== tab) return false;
|
||||
return `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase());
|
||||
if (!`${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase())) return false;
|
||||
if (catFilter && productCat(project.product) !== catFilter) return false;
|
||||
if (sourceFilter !== "all") {
|
||||
const hasScript = (project.script_versions?.length || 0) > 0;
|
||||
if (sourceFilter === "has" && !hasScript) return false;
|
||||
if (sourceFilter === "none" && hasScript) return false;
|
||||
}
|
||||
if (timeFilter !== "all" && project.created_at) {
|
||||
const days = (Date.now() - new Date(project.created_at).getTime()) / 86400000;
|
||||
if (days > Number(timeFilter)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
async function confirmDelete() {
|
||||
@@ -411,9 +470,9 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" id="proj-manage-btn">
|
||||
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="proj-manage-btn" onClick={() => setEditMode((v) => !v)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
|
||||
<span className="proj-manage-label">管理项目</span>
|
||||
<span className="proj-manage-label">{editMode ? "完成" : "管理项目"}</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
|
||||
@@ -433,9 +492,46 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
<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" data-key="product"><button className="chip" type="button"><span className="chip-label">商品品类</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>
|
||||
<div className="chip-wrap" data-key="source"><button className="chip" type="button"><span className="chip-label">脚本来源</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>
|
||||
<div className="chip-wrap" data-key="time"><button className="chip" type="button"><span className="chip-label">创建时间</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>
|
||||
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
|
||||
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
|
||||
<span className="chip-label">{catFilter || "商品品类"}</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${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); 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>
|
||||
{projectCategories.length > 0 && <div className="mi-sep" />}
|
||||
{projectCategories.map((cat) => (
|
||||
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); 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>{cat}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source">
|
||||
<button className={`chip${sourceFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
|
||||
<span className="chip-label">{sourceFilter === "all" ? "脚本来源" : srcLabel}</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">
|
||||
{SRC_OPTS.map((opt) => (
|
||||
<div className={`mi${sourceFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setSourceFilter(opt.value); 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>{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
|
||||
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
|
||||
<span className="chip-label">{timeFilter === "all" ? "创建时间" : timeLabel}</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">
|
||||
{TIME_OPTS.map((opt) => (
|
||||
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); 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>{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="spacer"></span>
|
||||
<div className="view-toggle">
|
||||
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
|
||||
@@ -490,6 +586,9 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
|
||||
<td className="muted-2">{projDate(project)}</td>
|
||||
<td>
|
||||
<div className="row-action">
|
||||
{editMode && (
|
||||
<a href="#" className="row-del" onClick={(event) => { event.preventDefault(); event.stopPropagation(); setDeleteTarget(project); }} title="删除项目"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" width="14" height="14"><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></a>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user