完善二期清单

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
+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>