feat(core/frontend): wire settings/avatar/image-gen + real data render (library/product-detail/pipeline)

App.tsx: thread saveProfile/changePassword/uploadAvatar/generateImages handlers + assets prop to pages.
- settings.tsx: profile save / password modal / avatar upload wired; notification/theme prefs -> localStorage
- library.tsx + product-detail: asset thumbnails + grids render real TOS preview_url
- ai-tools ImageWorkbenchPage: 生成图片 wired to /api/ai/generate-image, renders returned assets
- pipeline.tsx stage2-5: base_assets/storyboard/video_segments(adopted_asset)/timeline(clips/subtitles/bgm)
  rendered from real project data; graceful empty states
- types.ts: +VideoSegment.adopted_asset, +Timeline.subtitle_tracks/bgm_tracks
verified: tsc --noEmit clean; screenshots confirm pipeline stages 2-5 + product-detail render real data+images
(demo asset object_keys re-pointed to image objects so thumbnails resolve)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-06-05 16:20:10 +08:00
parent 603584b46b
commit 099bf0e6aa
11 changed files with 761 additions and 305 deletions

View File

@ -236,6 +236,28 @@ export function App() {
await reloadNotifications();
}
async function saveProfile(payload: { name?: string; phone?: string; email?: string }) {
const res = await action(() => api.updateProfile(payload), "资料已保存");
if (res) {
setUser(res.user);
setTeam(res.team);
}
}
async function changeOwnPassword(payload: { old_password: string; new_password: string }) {
const res = await action(() => api.changePassword(payload), "密码已修改");
if (res?.token) setToken(res.token);
}
async function uploadOwnAvatar(formData: FormData) {
const res = await action(() => api.uploadAvatar(formData), "头像已更新");
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return action(() => api.generateImage(payload), "图片已生成");
}
function onAuthed(payload: { token: string; user: User; team: Team }) {
setToken(payload.token);
setUser(payload.user);
@ -321,6 +343,7 @@ export function App() {
<ProductDetailPage
product={activeProduct}
projects={projects.filter((project) => project.product === activeProduct.id)}
assets={assets}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
/>
@ -404,19 +427,19 @@ export function App() {
case "assetFactory":
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} />;
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} />;
return <SettingsPage user={currentUser} team={currentTeam} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" />;
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
@ -437,6 +460,7 @@ export function App() {
team={currentTeam}
products={products}
projects={projects}
assets={assets}
billing={billing}
notice={notice}
avatarChar={avatarChar}

View File

@ -486,6 +486,14 @@
cursor: pointer;
}
.image-workbench .gen-image .placeholder { position: absolute; inset: 0; }
/* 生成结果真图 · 填满 .gen-image(比例由容器 aspect-ratio 控制) */
.image-workbench .gen-image-img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
display: block;
background: var(--black-alpha-4);
}
/* 右上浮层按钮组(§4.18 .gen-image-actions) */
.image-workbench .gen-image-actions {
position: absolute; top: 8px; right: 8px;

View File

@ -6,6 +6,8 @@
.asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
/* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.asset-body { padding: 12px 14px; }
.asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }

View File

@ -698,6 +698,8 @@
position: relative;
overflow: hidden;
}
/* 有 preview_url 显真图:铺满缩图、cover 裁切、继承圆角 */
.asset-card .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.asset-card .thumb .type-pill {
position: absolute; top: 8px; left: 8px;
padding: 3px 8px;

View File

@ -271,7 +271,8 @@ export function ImageWorkbenchPage({
assets,
modelConfigs,
onBack,
navigate
navigate,
onGenerate
}: {
mode: WorkMode;
products: Product[];
@ -279,6 +280,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
}) {
const meta = MODE_META[mode];
const [productId, setProductId] = useState(products[0]?.id || "");
@ -287,6 +289,8 @@ export function ImageWorkbenchPage({
const [ratio, setRatio] = useState(meta.ratio);
const [count, setCount] = useState("4");
const [pickedIds, setPickedIds] = useState<string[]>([]);
const [generating, setGenerating] = useState(false);
const [results, setResults] = useState<Asset[] | null>(null);
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
const modelOptions = useMemo(() => modelConfigs.slice(0, 6), [modelConfigs]);
@ -304,6 +308,19 @@ export function ImageWorkbenchPage({
const ratioVar = ratio.replace(":", " / ");
const candidateCount = Math.max(1, Number(count) || 4);
const canGenerate = prompt.trim().length > 0 && !generating;
async function runGenerate() {
if (!canGenerate) return;
setGenerating(true);
try {
const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount });
// 成功:渲染真图;失败(null):保留原占位,不改 results
if (result?.assets) setResults(result.assets);
} finally {
setGenerating(false);
}
}
return (
<div className="image-workbench">
@ -326,9 +343,9 @@ export function ImageWorkbenchPage({
A
</button>
)}
<button className="btn btn-primary" type="button">
<WandSparkles size={13} />
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
{generating ? <span className="spinner" aria-hidden /> : <WandSparkles size={13} />}
{generating ? "生成中…" : "生成图片"}
</button>
</div>
@ -485,9 +502,9 @@ export function ImageWorkbenchPage({
</div>
<div className="iw-cta">
<button className="btn btn-primary" type="button">
<WandSparkles size={13} />
<button className="btn btn-primary" type="button" onClick={runGenerate} disabled={!canGenerate}>
{generating ? <span className="spinner" aria-hidden /> : <WandSparkles size={13} />}
{generating ? "生成中…" : "立即生成"}
</button>
<div className="iw-cta-hint">
// {imageModels[0]?.display_name || "Volcano Image"} · 预估 {meta.title}
@ -513,14 +530,24 @@ export function ImageWorkbenchPage({
<span className="m-sep">|</span>
<span>{product?.title || meta.title}</span>
</div>
<div className="gen-images" style={{ "--cols": candidateCount >= 4 ? 4 : 2, "--ratio": ratioVar } as React.CSSProperties}>
{Array.from({ length: candidateCount }).map((_, index) => (
<div className="gen-image" key={index}>
<div className="placeholder">
<span className="ph-frame">
{ratio} · #{index + 1}
</span>
</div>
<div
className="gen-images"
style={{ "--cols": (results?.length ?? candidateCount) >= 4 ? 4 : 2, "--ratio": ratioVar } as React.CSSProperties}
>
{(results && results.length > 0
? results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url }))
: Array.from({ length: candidateCount }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined }))
).map(({ key, index, url }) => (
<div className="gen-image" key={key}>
{url ? (
<img className="gen-image-img" src={url} alt={`${meta.title} #${index + 1}`} loading="lazy" />
) : (
<div className="placeholder">
<span className="ph-frame">
{ratio} · #{index + 1}
</span>
</div>
)}
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="重跑">
<RefreshCw size={14} />

View File

@ -102,12 +102,17 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
{filtered.length ? (
<div className="asset-grid" id="asset-grid">
{filtered.map((asset) => (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
<div className="placeholder asset-thumb"><span className="ph-frame">{asset.asset_type}</span></div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
</article>
))}
{filtered.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
<div className="placeholder asset-thumb">
{cover ? <img src={cover} alt={asset.name} loading="lazy" /> : <span className="ph-frame">{asset.asset_type}</span>}
</div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
</article>
);
})}
</div>
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>

View File

@ -1,51 +1,48 @@
import { Fragment, useState } from "react";
import type { CSSProperties } from "react";
import { Play } from "lucide-react";
import type { BillingSummary, Product, Project, Team, User } from "../types";
import type { Asset, BillingSummary, Product, Project, Team, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { IconKitSvg } from "../components/IconKitSvg";
// 镜像 shell.js→mock-media.js 给 .placeholder 注入 mock 图;React 手动复刻对应映射
const mock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
// Stage 3 故事板 · 镜像 JS 注入的 3 场(全 mock)
const SB_SCENES = [
{ sid: "sc1", nm: "场 1", sub: "0-15s", frame: "深夜办公桌", img: "scene-office.png" },
{ sid: "sc2", nm: "场 2", sub: "15-30s", frame: "面膜包装/特写", img: "product-mask.png" },
{ sid: "sc3", nm: "场 3", sub: "30-45s", frame: "化妆台/产品定格", img: "cover-mask-final.png" }
];
const SB_PROMPT = "中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑";
// Stage 4 视频 · 镜像 3 场(全 mock,video-thumb 经 mock-media 映射封面图)
const VIDEO_CARDS = [
{ vid: "v1", frame: "场 1 · 0-15s", title: "场 1 · 深夜办公桌", meta: "15s · 1080×1920 · ¥0.45", img: "cover-mask-v3.png" },
{ vid: "v2", frame: "场 2 · 15-27s", title: "场 2 · 面膜包装/特写", meta: "12s · 1080×1920 · ¥0.45", img: "product-mask.png" },
{ vid: "v3", frame: "场 3 · 27-40s", title: "场 3 · 化妆台/产品定格", meta: "13s · 1080×1920 · ¥0.45", img: "cover-mask-final.png" }
];
// Stage 5 拼接编辑器 · 全 mock(镜像时间轴引擎按 data-dur 累计定位,React 直接算 left/width)
const ED_VIDEO_CLIPS = [
{ n: 1, lbl: "深夜办公桌", dur: 2 }, { n: 2, lbl: "面膜包装", dur: 3 }, { n: 3, lbl: "精华液微距", dur: 3 },
{ n: 4, lbl: "敷面膜平躺", dur: 3 }, { n: 5, lbl: "化妆台", dur: 2 }, { n: 6, lbl: "产品定格", dur: 2 }
];
const ED_SUB_CLIPS = [
{ lbl: "加班三天 脸已经不能看了…", dur: 2 }, { lbl: "还好我有这个 透真玻尿酸面膜", dur: 3 }, { lbl: "30g 精华 一片顶三片", dur: 3 },
{ lbl: "敷完起来脸是软的", dur: 3 }, { lbl: "化妆都能看出来", dur: 2 }, { lbl: "5 片 ¥39.9 囤起来", dur: 2 }
];
const ED_RULER: Array<{ left: string; major: boolean; t?: string }> = [
{ left: "0%", major: true, t: "0s" }, { left: "6.67%", major: false }, { left: "13.33%", major: true, t: "2s" }, { left: "20%", major: false },
{ left: "26.67%", major: true, t: "4s" }, { left: "33.33%", major: false }, { left: "40%", major: true, t: "6s" }, { left: "46.67%", major: false },
{ left: "53.33%", major: true, t: "8s" }, { left: "60%", major: false }, { left: "66.67%", major: true, t: "10s" }, { left: "73.33%", major: false },
{ left: "80%", major: true, t: "12s" }, { left: "86.67%", major: false }, { left: "93.33%", major: true, t: "14s" }, { left: "100%", major: true, t: "15s" }
];
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
function edLayout<T extends { dur: number }>(clips: T[]): Array<T & { leftPct: number; widthPct: number }> {
let acc = 0;
return clips.map((c) => { const left = acc; acc += c.dur; return { ...c, leftPct: (left / 15) * 100, widthPct: (c.dur / 15) * 100 }; });
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
// 基础资产组 kind → 中文区块名(对齐 design.md 商品/人物/场景三类)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "人物", scene: "场景" };
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
if (["failed", "error"].includes(status)) return "失败";
if (["running", "queued", "polling"].includes(status)) return "生成中";
if (status === "needs_review") return "待确认";
return "待生成";
}
// 毫秒 → mm:ss(时间轴 / 字幕展示)
function fmtMs(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
// 时间轴 clip 真实定位:start_ms / duration_ms 相对轨道总长(timeline.duration_seconds)算百分比
function clipLayout(startMs: number, durationMs: number, totalMs: number): { leftPct: number; widthPct: number } {
const total = totalMs > 0 ? totalMs : 1;
return { leftPct: Math.max(0, (startMs / total) * 100), widthPct: Math.max(0, Math.min(100, (durationMs / total) * 100)) };
}
// 标尺刻度:按轨道总秒数生成每秒一刻,偶数秒为主刻度带秒标
function buildRuler(totalSec: number): Array<{ leftPct: number; major: boolean; t?: string }> {
const secs = Math.max(1, Math.round(totalSec));
const ticks: Array<{ leftPct: number; major: boolean; t?: string }> = [];
for (let s = 0; s <= secs; s += 1) {
const major = s % 2 === 0;
ticks.push({ leftPct: (s / secs) * 100, major, t: major ? `${s}s` : undefined });
}
return ticks;
}
// 装饰用伪波形(BGM 轨铺底,纯视觉,无真实波形数据时使用)
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
const STAGE_STEPS: Array<{ n: number; label: string }> = [
{ n: 1, label: "脚本" },
{ n: 2, label: "基础资产" },
@ -62,6 +59,7 @@ export function PipelinePage(props: {
team: Team;
products: Product[];
projects: Project[];
assets: Asset[];
billing: BillingSummary | null;
notice: Notice | null;
avatarChar: string;
@ -79,11 +77,47 @@ export function PipelinePage(props: {
onSubmitExport: () => void;
}) {
const {
project, loading, navigate, user, team, products, projects, billing, notice, avatarChar, logout,
project, loading, navigate, user, team, products, projects, assets, billing, notice, avatarChar, logout,
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onPollVideo, onSubmitAllVideos, onPollAllVideos, onSubmitExport
onSubmitVideo, onSubmitAllVideos, onSubmitExport
} = props;
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
const byId = new Map(assets.map((a) => [a.id, a] as const));
const assetUrl = (id: string | null | undefined): string => {
if (!id) return "";
const a = byId.get(id);
return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || "";
};
const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : "");
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
const groups = project.base_asset_groups ?? [];
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
// ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ──
const storyboards = project.storyboard_versions ?? [];
const adoptedStoryboard = storyboards.find((s) => s.is_adopted) || storyboards[0] || null;
const sbFrames = [...(adoptedStoryboard?.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const [sbSelected, setSbSelected] = useState(0);
const sbActiveFrame = sbFrames[Math.min(sbSelected, Math.max(0, sbFrames.length - 1))] || null;
// ── Stage 4:视频片段(adopted_asset 缩略图 + 状态 pill + 时长)──
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
// ── Stage 5:时间轴 / 字幕 / BGM(真实定位,轨道总长用 timeline.duration_seconds)──
const timeline = project.timeline;
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000;
const ruler = buildRuler(tlRulerMs / 1000);
const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms);
const bgmTracks = timeline?.bgm_tracks ?? [];
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
@ -96,8 +130,10 @@ export function PipelinePage(props: {
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
// 真实商品名(api-bridge 仅 hydrate 商品名,其余人物/场景沿用设计稿 mock)
const productName = products.find((item) => item.id === project.product)?.title || "透真补水面膜";
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
const productRecord = products.find((item) => item.id === project.product);
const productName = productRecord?.title || "透真补水面膜";
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
function goStage(n: number) {
setViewStage(n);
@ -238,14 +274,24 @@ export function PipelinePage(props: {
</div>
</section>
{/* Stage 2-5 · 暂沿用既有功能性结构(默认隐藏),后续逐阶段做像素还原 */}
{viewStage === 2 && (
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
{viewStage === 2 && (() => {
const productGroup = groupsByKind("product")[0] || null;
const productAssetUrl = assetUrl(productGroup?.adopted_asset) || assetUrl(productGroup?.candidate_assets?.[0]) || assetUrl(productCover);
const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
return (
<section className="stage active" data-stage-pane="2">
<div className="stage-assets">
<div className="asset-side">
<div className="ttab active" data-jump="asset-sec-products"><span></span><span className="num">3 </span></div>
<div className="ttab" data-jump="asset-sec-characters"><span></span><span className="num">2/2</span></div>
<div className="ttab" data-jump="asset-sec-scenes"><span></span><span className="num">3/3</span></div>
{KIND_ORDER.map((kind) => {
const list = groupsByKind(kind);
const adopted = list.filter((g) => g.adopted_asset).length;
return (
<div className={`ttab${kind === "product" ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`}>
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
</div>
);
})}
<div className="info">
,
<br /><br />
@ -256,29 +302,31 @@ export function PipelinePage(props: {
</div>
<div className="asset-main">
<section className="asset-sec" id="asset-sec-products">
<section className="asset-sec" id="asset-sec-product">
<div className="sec-h"><h3> · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
<div className="prod-row">
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id="prod-main" id="asset-prod-card">
<div className="placeholder prod-thumb has-mock-media" style={mock("product-mask.png")}>
<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>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id={productGroup?.adopted_asset || "prod-main"} id="asset-prod-card">
<div className={`placeholder prod-thumb${productAssetUrl ? " has-mock-media" : ""}`} style={productAssetUrl ? mediaStyle(productAssetUrl) : undefined}>
{!productGroup?.adopted_asset && (
<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>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body"> <b> / / </b> ,,姿</span>
<span className="pop-tip">建议:点右下 <b>AI </b> ,</span>
</span>
<span className="pop-body"> <b> / / </b> ,,姿</span>
<span className="pop-tip">建议:点右下 <b>AI </b> ,</span>
</span>
</span>
)}
<span className="ph-frame" id="asset-prod-thumb-label">{productName} · </span>
</div>
<div className="prod-body">
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
<div className="prod-cat"></div>
<div className="prod-date">2026-05-15 </div>
<div className="prod-cat">{products.find((item) => item.id === project.product)?.category || "未分类"}</div>
<div className="prod-date">{(project.created_at || "").slice(0, 10)} </div>
</div>
<div className="prod-action" id="asset-prod-action">
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
@ -287,109 +335,108 @@ export function PipelinePage(props: {
</button>
</div>
</div>
<div className="prod-preview" id="asset-prod-preview">
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">生成中</span></div>
<div className="placeholder prod-preview-img" id="prod-preview-img"></div>
<div className="prod-preview-foot" id="prod-preview-foot"></div>
</div>
</div>
</section>
<section className="asset-sec" id="asset-sec-characters">
<div className="sec-h"><h3> · 2 </h3><span className="spacer"></span></div>
<div className="asset-grid-2">
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-linxi">
<div className="placeholder thumb-2 has-mock-media" style={mock("person-linxi.png")}><span className="ph-frame"> · </span></div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}> · </strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 ,,穿,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-anan">
<div className="placeholder thumb-2">
<div style={{ display: "flex", flexDirection: "column", gap: "8px", alignItems: "center" }}>
<div className="spinner"></div>
<span className="ph-frame"> · 8s</span>
</div>
</div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>/ · </strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 ,,穿,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop disabled></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop disabled></button></div>
<div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
<div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
<div className={`placeholder prod-preview-img${assetUrl(productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={assetUrl(productCandidates[0]) ? mediaStyle(assetUrl(productCandidates[0])) : undefined}><span className="ph-frame"> #1</span></div>
<div className="prod-preview-foot" id="prod-preview-foot">
{productCandidates.slice(0, 4).map((id) => (
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
))}
</div>
</div>
</div>
</section>
<section className="asset-sec" id="asset-sec-scenes">
<div className="sec-h"><h3> · 3 </h3><span className="spacer"></span></div>
<div className="asset-grid-2">
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-desk">
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-office.png")}><span className="ph-frame"></span></div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}></strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>,,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-bed">
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-bedroom.png")}><span className="ph-frame"></span></div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}></strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-subway">
<div className="placeholder thumb-2">
<div style={{ display: "flex", flexDirection: "column", gap: "6px", alignItems: "center" }}>
<div className="fail-icon">!</div>
<span className="ph-frame"></span>
{(["person", "scene"] as const).map((kind) => {
const list = groupsByKind(kind);
return (
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
<div className="sec-h"><h3>{KIND_LABEL[kind]} · {list.length} </h3><span className="spacer"></span></div>
{list.length ? (
<div className="asset-grid-2">
{list.map((group, gi) => {
const mainUrl = assetUrl(group.adopted_asset) || assetUrl(group.candidate_assets?.[0]);
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
<span className="ph-frame">{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</span>
</div>
<div className="body-2">
<div className="hstack">
<strong style={{ fontSize: "13.5px" }}>{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</strong>
<span className="spacer"></span>
{group.adopted_asset
? <span className="pill ok"><span className="dot"></span></span>
: <span className="pill neutral"><span className="dot"></span></span>}
</div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>{group.prompt || "(暂无提示词)"}</div>
{cands.length > 0 && (
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
{cands.map((id) => (
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}></strong><span className="spacer"></span><span className="pill err"><span className="dot"></span></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>,线,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
</div>
</section>
) : (
<div className="placeholder" style={{ minHeight: "120px" }}><span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 待生成</span></div>
)}
</section>
);
})}
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ ¥0.85 · ¥0.20 · ¥0() ]</span></div>
<div className="info"><span className="mono">[ · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(1)}><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>
<button className="btn btn-primary btn-lg" 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="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
)}
);
})()}
{/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */}
{viewStage === 3 && (
<section className="stage active" data-stage-pane="3">
<div className="stage-storyboard">
<div className="sb-canvas">
<div className="sb-scenes-col" id="sb-scenes-row">
{SB_SCENES.map((scene) => (
<div className={`sb-scene-thumb${scene.sid === "sc1" ? " selected" : ""}`} key={scene.sid} data-sid={scene.sid}>
<div className="placeholder has-mock-media" style={mock(scene.img)}><span className="ph-frame">{scene.frame}</span></div>
<div className="nm">{scene.nm}</div>
<div className="sub">{scene.sub}</div>
</div>
))}
{sbFrames.length ? sbFrames.map((frame, idx) => {
const url = assetUrl(frame.asset);
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame"> {idx + 1}</span></div>
<div className="nm"> {idx + 1}</div>
<div className="sub">#{frame.sort_order + 1}</div>
</div>
);
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div>
<div className="placeholder sb-main-img has-mock-media" id="sb-main-img" style={mock("cover-mask-v3.png")}><span className="ph-frame"> 1 · · v1</span></div>
{(() => {
const url = assetUrl(sbActiveFrame?.asset);
return (
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame">{sbActiveFrame ? `${sbSelected + 1}` : "// 故事板未生成"}</span>
</div>
);
})()}
</div>
<div className="sb-side">
<div className="pane" style={{ padding: "18px" }}>
<div className="hstack" style={{ marginBottom: "10px" }}>
<strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene"> 1</span></strong>
<strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene">{sbActiveFrame ? `${sbSelected + 1}` : "—"}</span></strong>
<span className="spacer"></span>
<span className="pill ok"><span className="dot"></span></span>
{adoptedStoryboard
? <span className="pill ok"><span className="dot"></span></span>
: <span className="pill neutral"><span className="dot"></span></span>}
</div>
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}> image-2 , + </div>
<div className="sb-rerun-note">
@ -399,7 +446,7 @@ export function PipelinePage(props: {
<div className="note-copy"><strong></strong> · , <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 </a> ,</div>
</div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 本场提示词</div>
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{SB_PROMPT}</div>
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{sbActiveFrame?.prompt || adoptedStoryboard?.prompt || "(暂无提示词)"}</div>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" 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>
@ -409,26 +456,32 @@ export function PipelinePage(props: {
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/</span>
</div>
<div className="sb-history">
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">1</span>)</div>
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
<div className="sb-history-row" id="sb-history-row">
<div className="sb-history-thumb current" data-vi="0">
<div className="placeholder has-mock-media" style={mock("cover-mask-final.png")}><span className="ph-frame">v1</span></div>
<div className="ts">14:02</div>
</div>
{storyboards.length ? storyboards.map((ver) => {
const cover = assetUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]?.asset);
return (
<div className={`sb-history-thumb${ver.is_adopted ? " current" : ""}`} key={ver.id} data-vi={ver.id}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
<div className="ts">{(ver.created_at || "").slice(11, 16) || "--:--"}</div>
</div>
);
}) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无历史</span>}
</div>
</div>
<div className="divider" style={{ marginTop: "16px" }}></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
<span className="asset-tag"><span className="dotc"></span>()</span>
<span className="asset-tag"><span className="dotc"></span>()</span>
{groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => (
<span className="asset-tag" key={g.id}><span className="dotc"></span>{assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind})</span>
)) : <span className="muted-2 mono" style={{ fontSize: "11px" }}>// 暂无绑定资产</span>}
</div>
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ image-2 ¥0.45 · ¥1.35 · , ]</span></div>
<div className="info"><span className="mono">[ image-2 · {sbFrames.length} · , ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(2)}><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>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>, <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>
@ -436,61 +489,83 @@ export function PipelinePage(props: {
</div>
</section>
)}
{viewStage === 4 && (
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
{viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
return (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
<div>
<div style={{ fontSize: "14px", fontWeight: 600 }}> · 3 / 3 </div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 <span id="seedance-avg">15</span> 秒 · 已完成所有场次</div>
<div style={{ fontSize: "14px", fontWeight: 600 }}> · {segDone} / {segments.length} </div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {segments.length ? (segDone === segments.length ? "已完成所有场次" : "生成中") : "暂无片段"}</div>
</div>
<div className="bar-wrap"><span style={{ width: "100%" }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>100%</span>
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}> </button>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => onSubmitAllVideos(videoPrompt)}> </button>
<button className="btn btn-sm" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
</button>
</div>
<div className="video-grid" id="video-grid">
{VIDEO_CARDS.map((card) => (
<div className="video-card" key={card.vid} data-video-id={card.vid}>
<div className="placeholder video-thumb has-mock-media" style={mock(card.img)}>
<span className="ph-frame">{card.frame}</span>
<div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>
</div>
<div className="body">
<div className="video-card-head"><strong className="video-card-title">{card.title}</strong><span className="pill ok"><span className="dot"></span></span></div>
<div className="video-meta">{card.meta}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop></button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-vstop></button>
{segments.length ? (
<div className="video-grid" id="video-grid">
{segments.map((seg) => {
const url = assetUrl(seg.adopted_asset);
const tone = statusPill(seg.status);
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className={`placeholder video-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame"> {seg.sort_order + 1}</span>
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
</div>
<div className="body">
<div className="video-card-head">
<strong className="video-card-title"> {seg.sort_order + 1}</strong>
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
</div>
<div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`)}></button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={!url}></button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
);
})}
</div>
) : (
<div className="placeholder" style={{ minHeight: "200px", margin: "18px 28px" }}><span className="ph-frame">// 暂无视频片段 · 先在故事板确认后生成</span></div>
)}
<div className="stage-foot">
<div className="info"><span className="mono">[ 3 · ¥1.35 · <span id="seedance-total">40</span>s · · ]</span></div>
<div className="info"><span className="mono">[ {segDone} · {segTotalSec}s · · ]</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>
<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>
</div>
</div>
</section>
)}
{viewStage === 5 && (
);
})()}
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
{viewStage === 5 && (() => {
const previewUrl = assetUrl(tlClips[0]?.asset) || assetUrl(segments.find((s) => s.adopted_asset)?.adopted_asset);
const aspect = timeline?.aspect_ratio || "9:16";
const resolution = timeline?.resolution || "1080×1920";
const bgm = bgmTracks[0] || null;
const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : "");
return (
<section className="stage active" data-stage-pane="5">
<div className="editor">
<div className="editor-preview">
<div className="canvas has-mock-media" id="ed-canvas" style={{ backgroundImage: "url(/exact/assets/mock/cover-mask-final.png)" }}><span id="ed-canvas-label">9:16 · 1080×1920</span></div>
<div className={`canvas${previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={previewUrl ? mediaStyle(previewUrl) : undefined}><span id="ed-canvas-label">{aspect} · {resolution}</span></div>
<div className="controls">
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">00:00.00</span> / <span id="ed-total-time">00:15.00</span></span>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">0:00</span> / <span id="ed-total-time">{fmtMs(tlRulerMs)}</span></span>
</div>
</div>
@ -504,15 +579,18 @@ export function PipelinePage(props: {
<div className="swatch-card"><div className="demo d"></div><div className="nm"></div></div>
</div>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 当前选中(<span id="ed-inspect-name">未选</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-start" defaultValue="—" /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-dur" defaultValue="—" /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue="100" /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue="1.0x" /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}></span></div>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}> · 0:42</span><button className="btn btn-ghost btn-sm" type="button"></button></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-start" defaultValue={fmtMs(tlRulerMs)} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-dur" defaultValue={`${tlClips.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue={`${subtitleCues.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
{bgm && (
<>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>{bgmName} · {bgm.volume}</span><button className="btn btn-ghost btn-sm" type="button"></button></div>
</>
)}
</div>
<div className="timeline" id="ed-timeline">
@ -530,8 +608,8 @@ export function PipelinePage(props: {
<div className="tl-ruler">
<div className="l">// time</div>
<div className="rule-track" id="ed-ruler">
{ED_RULER.map((tick, i) => (
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: tick.left }}>{tick.t && <span className="t">{tick.t}</span>}</span>
{ruler.map((tick, i) => (
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
))}
</div>
</div>
@ -539,47 +617,66 @@ export function PipelinePage(props: {
<div className="tl-track video-track">
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span></div>
<div className="lane" id="ed-lane-video" data-track="video">
{edLayout(ED_VIDEO_CLIPS).map((clip) => (
<div className="clip video" key={clip.n} data-track="video" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}>
<span className="frames">{Array.from({ length: clip.dur + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
<span className="num">{clip.n}</span><span className="lbl">{clip.lbl}</span>
</div>
))}
{tlClips.length ? tlClips.map((clip, idx) => {
const { leftPct, widthPct } = clipLayout(clip.start_ms, clip.duration_ms, tlRulerMs);
const lbl = assetName(clip.asset) || `片段 ${idx + 1}`;
const frameCount = Math.max(1, Math.round(clip.duration_ms / 1000));
return (
<div className="clip video" key={clip.id} data-track="video" data-label={lbl} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
<span className="frames">{Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
<span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
</div>
);
}) : <span className="muted-2 mono" style={{ position: "absolute", left: "8px", top: "50%", transform: "translateY(-50%)", fontSize: "11px" }}>// 暂无片段</span>}
</div>
</div>
<div className="tl-track subtitle-track">
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span></div>
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
{edLayout(ED_SUB_CLIPS).map((clip, i) => (
<div className="clip subtitle" key={i} data-track="subtitle" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}><span className="lbl">{clip.lbl}</span></div>
))}
{subtitleCues.map((cue, i) => {
const next = subtitleCues[i + 1];
const endMs = next ? next.start_ms : tlRulerMs;
const { leftPct, widthPct } = clipLayout(cue.start_ms, Math.max(0, endMs - cue.start_ms), tlRulerMs);
return (
<div className="clip subtitle" key={i} data-track="subtitle" data-label={cue.text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{cue.text}</span></div>
);
})}
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
</div>
</div>
<div className="tl-track bgm-track">
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
<div className="lane">
<div className="clip bgm" data-track="bgm" data-label="温柔治愈钢琴" style={{ left: "0%", width: "100%" }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl"> · 0:42( 1 ,)</span>
{bgmTracks.length > 0 && (
<div className="tl-track bgm-track">
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
<div className="lane">
{bgmTracks.map((track) => {
const { leftPct, widthPct } = clipLayout(track.start_ms, Math.max(0, tlRulerMs - track.start_ms), tlRulerMs);
const name = assetName(track.asset) || "背景音乐";
return (
<div className="clip bgm" key={track.id} data-track="bgm" data-label={name} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl">{name} · {track.volume}</span>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ ~30s · / 0 token · ¥1.39 ]</span></div>
<div className="info"><span className="mono">[ {fmtMs(tlRulerMs)} · {tlClips.length} · / 0 token ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(4)}><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>
<button className="btn" type="button">稿</button>
<button className="btn btn-primary btn-lg" type="button" onClick={onSubmitExport}> MP4 · 1080P 9:16 <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></button>
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport} onClick={onSubmitExport}> MP4 · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect} <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></button>
</div>
</div>
</section>
)}
);
})()}
</div>
</main>
</div>

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import type { CSSProperties, FormEvent, KeyboardEvent } from "react";
import { ArrowLeft } from "lucide-react";
import type { Product, Project } from "../types";
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import { Drawer } from "../components/overlays";
import "../product-create-page.css";
@ -303,46 +303,60 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
}
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
// 真实数据仅注入 api-bridge renderProductDetail 实际 hydrate 的 4 个字段
// (名称 / 品类 / 目标人群 / 卖点),其余 图片/素材/项目 网格沿用设计稿镜像的
// 静态占位(api-bridge 在 ?product_id 加载时同样保留 mock,故像素对齐)。
const PD_ASSETS: Array<{ type: string; status: "pass" | "fail" | "archive" }> = [
{ type: "模特上身图", status: "pass" },
{ type: "模特上身图", status: "pass" },
{ type: "模特上身图", status: "fail" },
{ type: "模特上身图", status: "pass" },
{ type: "模特上身图", status: "archive" },
{ type: "平台套图", status: "pass" },
{ type: "平台套图", status: "pass" },
{ type: "平台套图", status: "fail" },
{ type: "平台套图", status: "archive" },
{ type: "平台套图", status: "pass" },
{ type: "三视图", status: "pass" },
{ type: "三视图", status: "archive" }
];
// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡
// 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。
const PD_ASSET_STATUS_LABEL: Record<"pass" | "fail" | "archive", string> = { pass: "通过", fail: "不通过", archive: "归档" };
const PD_VIDEOS: Array<{ proj: string; pill: string; ver: string; label: string; date: string }> = [
{ proj: "done", pill: "ok", ver: "补水面膜 · v3", label: "已完成", date: "2026-05-20 12:08" },
{ proj: "wip", pill: "info", ver: "补水面膜 · v2", label: "视频生成 4/6", date: "2026-05-19 10:24" },
{ proj: "archived", pill: "neutral", ver: "熬夜急救 · v1", label: "已归档", date: "2026-05-18 21:42" },
{ proj: "fail", pill: "err", ver: "补水面膜 · v1", label: "故事板失败", date: "2026-05-17 16:00" }
];
const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
export function ProductDetailPage({ product, navigate, onUpdate }: {
// 取一个 Asset 的预览图(优先主文件,其次首文件)
function pdAssetPreview(asset?: Asset): string {
if (!asset) return "";
return asset.files?.find((file) => file.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
}
// AI 素材分类 → 中文类型标签(用于缩图左上角 type-pill)
const PD_ASSET_TYPE_LABEL: Record<string, string> = {
product_image: "商品图", person: "模特上身图", scene: "平台套图", tri_view: "三视图", background: "背景图"
};
function pdAssetTypeLabel(asset: Asset): string {
return PD_ASSET_TYPE_LABEL[asset.category] || PD_ASSET_TYPE_LABEL[asset.asset_type] || asset.category || "素材";
}
// 项目状态 → 分桶 / 友好标签 / pill 类(对齐 projects.tsx 语义,组件内自洽)
function pdProjBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
function pdProjStatusLabel(project: Project) {
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
}
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
export function ProductDetailPage({ product, projects, assets, navigate, onUpdate }: {
product: Product;
projects: Project[];
assets: Asset[];
navigate: (page: Page) => void;
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
}) {
const [tab, setTab] = useState<"assets" | "videos">("assets");
const [editing, setEditing] = useState(false);
const [triOpen, setTriOpen] = useState(false);
// 素材状态筛选 · 镜像默认即「通过」(api-bridge ALWAYS_APPLY status),只显示通过卡
const [assetStatus] = useState<"pass" | "fail" | "archive">("pass");
const assetCount = PD_ASSETS.filter((asset) => asset.status === assetStatus).length;
// 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重)
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const imageIds = imageRefs.map((ref) => ref.asset);
if (product.cover_asset && !imageIds.includes(product.cover_asset)) imageIds.unshift(product.cover_asset);
const productImages = imageIds
.map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) }));
// AI 生成素材 · 团队资产中筛与该商品相关的类别(模特/场景/三视图/商品图/背景),取真图;无则回退到全部图片资产
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
const aiSource = assets.filter((asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type));
const imageAssets = (aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image"))
.slice()
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
const assetCount = imageAssets.length;
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段
const videoProjects = projects;
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
const realName = product.title || "补水保湿精华液";
@ -470,15 +484,14 @@ export function ProductDetailPage({ product, navigate, onUpdate }: {
<div className="ov-images-sub">
<div className="sub-h">
<span className="ti"></span>
<span className="ct">(6)</span>
<span className="ct">({productImages.length})</span>
</div>
<div className="grid" id="ov-images-grid">
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
{productImages.map((image) => (
<div className="thumb placeholder" key={image.id}>
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
</div>
))}
<div className="img-upload" id="ov-img-add" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</div>
@ -557,13 +570,17 @@ export function ProductDetailPage({ product, navigate, onUpdate }: {
</div>
<div className="asset-grid">
{PD_ASSETS.map((asset, index) => {
// 镜像 mock-media:平台套图 占位匹配 scene → scene-tabletop.png
const hasMock = asset.type === "平台套图";
{imageAssets.map((asset) => {
const url = pdAssetPreview(asset);
const status: "pass" | "fail" | "archive" = "pass";
return (
<div className="asset-card" key={index} style={asset.status === assetStatus ? undefined : { display: "none" }}>
<div className={`thumb placeholder${hasMock ? " has-mock-media" : ""}`} style={hasMock ? ({ "--mock-media-url": "url(/exact/assets/mock/scene-tabletop.png)" } as CSSProperties) : undefined}><span className="type-pill">{asset.type}</span><span className="ph-frame">3:4</span></div>
<div className="meta"><span className={`pill ${asset.status}`} data-status={asset.status} title="点击切换状态">{PD_ASSET_STATUS_LABEL[asset.status]}</span><span className="date">2026-05-19 15:30</span></div>
<div className="asset-card" key={asset.id}>
<div className="thumb placeholder">
{url ? <img src={url} alt={asset.name} loading="lazy" /> : null}
<span className="type-pill">{pdAssetTypeLabel(asset)}</span>
{url ? null : <span className="ph-frame">3:4</span>}
</div>
<div className="meta"><span className={`pill ${status}`} data-status={status}>{PD_ASSET_STATUS_LABEL[status]}</span><span className="date">{(asset.created_at || "").slice(0, 10)}</span></div>
</div>
);
})}
@ -575,7 +592,7 @@ export function ProductDetailPage({ product, navigate, onUpdate }: {
{/* ===== 视频项目 ===== */}
<div className={`tab-pane${tab === "videos" ? " active" : ""}`} data-pane="videos">
<div className="pd-toolbar">
<div className="total"> <span className="ct">(4)</span></div>
<div className="total"> <span className="ct">({videoProjects.length})</span></div>
<div className="right">
<button className="filter" type="button" data-key="sort">
@ -585,10 +602,10 @@ export function ProductDetailPage({ product, navigate, onUpdate }: {
</div>
<div className="asset-grid">
{PD_VIDEOS.map((video, index) => (
<div className="asset-card" data-proj-status={video.proj} key={index}>
<div className="thumb placeholder" style={{ aspectRatio: "9/16" }}><span className="type-pill"> · 9:16</span><span className="ph-frame">{video.ver}</span></div>
<div className="meta"><span className={`pill ${video.pill}`}><span className="dot"></span>{video.label}</span><span className="date">{video.date}</span></div>
{videoProjects.map((project) => (
<div className="asset-card" data-proj-status={pdProjBucket(project)} key={project.id}>
<div className="thumb placeholder" style={{ aspectRatio: "9/16" }}><span className="type-pill"> · 9:16</span><span className="ph-frame">{project.name}</span></div>
<div className="meta"><span className={`pill ${pdProjPillClass(project)}`}><span className="dot"></span>{pdProjStatusLabel(project)}</span><span className="date">{(project.updated_at || "").slice(0, 10)}</span></div>
</div>
))}
</div>

View File

@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ReactNode } from "react";
import {
Bell,
KeyRound,
LogOut,
Monitor,
ShieldCheck,
@ -64,6 +65,46 @@ const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: s
{ key: "n-login", title: "异地登录告警", channels: "短信" },
];
// ─── 偏好持久化 · 后端无字段,纯本地 localStorage ───
const PREFS_KEY = "airshelf_settings_prefs";
type Prefs = {
template: string;
duration: string;
subtitle: string;
twoFactor: boolean;
notify: Record<string, boolean>;
appearance: string;
language: string;
density: string;
};
const DEFAULT_PREFS: Prefs = {
template: "pain",
duration: "60",
subtitle: "big-variety",
twoFactor: false,
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true },
appearance: "system",
language: "zh",
density: "standard",
};
function loadPrefs(): Prefs {
try {
const raw = localStorage.getItem(PREFS_KEY);
if (!raw) return DEFAULT_PREFS;
const parsed = JSON.parse(raw) as Partial<Prefs>;
return {
...DEFAULT_PREFS,
...parsed,
notify: { ...DEFAULT_PREFS.notify, ...(parsed.notify ?? {}) },
};
} catch {
return DEFAULT_PREFS;
}
}
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
return (
<label className="switch">
@ -73,20 +114,141 @@ function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?:
);
}
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
export function SettingsPage({
user,
team,
initialSection = "profile",
onSaveProfile,
onChangePassword,
onUploadAvatar,
}: {
user: User;
team: Team;
initialSection?: string;
onSaveProfile: (payload: { name?: string; phone?: string; email?: string }) => void | Promise<unknown>;
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
}) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
: "profile";
const [section, setSection] = useState<SectionKey>(normalizedInitial);
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">("");
const [template, setTemplate] = useState("pain");
const [duration, setDuration] = useState("60");
const [subtitle, setSubtitle] = useState("big-variety");
const [twoFactor, setTwoFactor] = useState(false);
const [notify, setNotify] = useState<Record<string, boolean>>({ "n-export": true, "n-fail": true, "n-quota": true, "n-login": true });
// 个人信息 · 受控输入(初值取真实用户数据)
const [name, setName] = useState(user.username || "");
const [email, setEmail] = useState(user.email || "");
const [phone, setPhone] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const avatarChar = useMemo(() => (user.username || "李").slice(0, 1).toUpperCase(), [user.username]);
// 偏好 · localStorage 持久化(读 localStorage 初始化)
const initialPrefs = useMemo(() => loadPrefs(), []);
const [template, setTemplate] = useState(initialPrefs.template);
const [duration, setDuration] = useState(initialPrefs.duration);
const [subtitle, setSubtitle] = useState(initialPrefs.subtitle);
const [twoFactor, setTwoFactor] = useState(initialPrefs.twoFactor);
const [notify, setNotify] = useState<Record<string, boolean>>(initialPrefs.notify);
const [appearance, setAppearance] = useState(initialPrefs.appearance);
const [language, setLanguage] = useState(initialPrefs.language);
const [density, setDensity] = useState(initialPrefs.density);
// 偏好改动即写回 localStorage(不调后端)
useEffect(() => {
const prefs: Prefs = { template, duration, subtitle, twoFactor, notify, appearance, language, density };
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch {
/* localStorage 不可用时静默降级 */
}
}, [template, duration, subtitle, twoFactor, notify, appearance, language, density]);
// 改密 · 受控输入
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [pwSubmitted, setPwSubmitted] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const pwTooShort = newPassword.length > 0 && newPassword.length < 8;
const pwReady = oldPassword.length > 0 && newPassword.length >= 8;
// 头像 · 文件选择 + 本地预览
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>("");
const [savingAvatar, setSavingAvatar] = useState(false);
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
function resetProfile() {
setName(user.username || "");
setEmail(user.email || "");
setPhone("");
}
async function handleSaveProfile() {
if (savingProfile) return;
setSavingProfile(true);
try {
await onSaveProfile({ name: name.trim(), email: email.trim(), phone: phone.trim() });
} finally {
setSavingProfile(false);
}
}
function openPasswordModal() {
setOldPassword("");
setNewPassword("");
setPwSubmitted(false);
setModal("password");
}
async function handleChangePassword() {
setPwSubmitted(true);
if (!pwReady || savingPassword) return;
setSavingPassword(true);
try {
await onChangePassword({ old_password: oldPassword, new_password: newPassword });
setModal("");
setOldPassword("");
setNewPassword("");
setPwSubmitted(false);
} finally {
setSavingPassword(false);
}
}
function openAvatarModal() {
setAvatarFile(null);
setAvatarPreview("");
setModal("avatar");
}
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setAvatarFile(file);
setAvatarPreview(URL.createObjectURL(file));
}
async function handleUploadAvatar() {
if (!avatarFile || savingAvatar) return;
setSavingAvatar(true);
try {
const fd = new FormData();
fd.append("file", avatarFile);
await onUploadAvatar(fd);
setModal("");
setAvatarFile(null);
setAvatarPreview("");
} finally {
setSavingAvatar(false);
}
}
// 预览 URL 在切换/卸载时释放,避免内存泄漏
useEffect(() => {
if (!avatarPreview) return;
return () => URL.revokeObjectURL(avatarPreview);
}, [avatarPreview]);
return (
<section className="settings-page">
@ -96,8 +258,8 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
</div>
<div className="actions">
<button className="btn" type="button" disabled></button>
<button className="btn btn-primary" type="button" disabled>
<button className="btn" type="button" onClick={resetProfile} disabled={savingProfile}></button>
<button className="btn btn-primary" type="button" onClick={handleSaveProfile} disabled={savingProfile}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
</button>
@ -150,7 +312,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="avatar-edit">
<div className="av-big">{avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}></button>
<button className="btn btn-sm" type="button" onClick={openAvatarModal}></button>
<button className="btn btn-ghost btn-sm" type="button"></button>
</div>
</div>
@ -158,19 +320,19 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
</div>
<div className="form-row">
<div className="lbl"><span className="req">*</span></div>
<div className="val"><input className="input" defaultValue={user.username} /></div>
<div className="val"><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" defaultValue={user.email || ""} />
<input className="input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
<button className="btn btn-ghost btn-sm" type="button"></button>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" defaultValue="138****8000" />
<input className="input" value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="138****8000" />
<button className="btn btn-ghost btn-sm" type="button"></button>
</div>
</div>
@ -199,7 +361,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="val">
<span className="static mono"></span>
<span className="row-note" style={{ marginLeft: "auto" }}> 2026-04-12</span>
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }}></button>
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }} onClick={openPasswordModal}></button>
</div>
</div>
<div className="form-row">
@ -358,7 +520,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="system">
<select className="select" value={appearance} onChange={(event) => setAppearance(event.target.value)}>
<option value="system"></option>
<option value="light"></option>
<option value="dark" disabled>(V2)</option>
@ -368,7 +530,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="zh">
<select className="select" value={language} onChange={(event) => setLanguage(event.target.value)}>
<option value="zh"></option>
<option value="en" disabled>English(V2)</option>
</select>
@ -377,7 +539,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="standard">
<select className="select" value={density} onChange={(event) => setDensity(event.target.value)}>
<option value="compact"></option>
<option value="standard"></option>
<option value="loose"></option>
@ -391,7 +553,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
</main>
</div>
{/* 上传头像 modal · 仅视觉还原,无后端接入 */}
{/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */}
<TeamModal
open={modal === "avatar"}
title="上传头像"
@ -399,25 +561,46 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
icon={<Upload size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
<button className="btn btn-primary" type="button" onClick={handleUploadAvatar} disabled={!avatarFile || savingAvatar}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
使
</button>
}
>
<div className="av-up-preview-row">
<div className="av-up-preview">{avatarChar}</div>
<div className="av-up-preview">
{avatarPreview ? <img src={avatarPreview} alt="头像预览" /> : avatarChar}
</div>
<div className="av-up-preview-meta">
<div className="t"> · </div>
<div className="d">// 系统生成 · 取姓氏首字</div>
<div className="t">{avatarFile ? avatarFile.name : "当前头像 · 默认"}</div>
<div className="d">{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}</div>
</div>
</div>
<div className="upload-zone" role="button" tabIndex={0} aria-label="点击或拖入图片上传">
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={onPickAvatar}
/>
<div
className="upload-zone"
role="button"
tabIndex={0}
aria-label="点击选择图片上传"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
fileInputRef.current?.click();
}
}}
>
<span className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
</span>
<div><strong></strong> · </div>
<div><strong></strong> · </div>
<span className="uz-hint">JPG / PNG / WebP · 2 MB · 256 × 256</span>
</div>
@ -427,6 +610,50 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
</div>
</TeamModal>
{/* 修改密码 modal · 原密码 + 新密码(≥8)→ onChangePassword */}
<TeamModal
open={modal === "password"}
title="修改登录密码"
subtitle="// CHANGE PASSWORD"
icon={<KeyRound size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={handleChangePassword} disabled={!pwReady || savingPassword}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
</button>
}
>
<div className="field">
<label className="field-label" htmlFor="pw-old"><span className="req">*</span></label>
<input
id="pw-old"
className="input"
type="password"
autoComplete="current-password"
value={oldPassword}
onChange={(event) => setOldPassword(event.target.value)}
placeholder="输入当前密码"
/>
{pwSubmitted && !oldPassword ? <span className="field-hint pw-err"></span> : null}
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label" htmlFor="pw-new"><span className="req">*</span></label>
<input
id="pw-new"
className="input"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
placeholder="至少 8 位"
/>
{pwTooShort || (pwSubmitted && newPassword.length < 8)
? <span className="field-hint pw-err"> 8 </span>
: <span className="field-hint">// 建议混合字母、数字与符号</span>}
</div>
</TeamModal>
{/* 退出登录确认 modal · 仅视觉还原,无后端接入 */}
<TeamModal
open={modal === "logout"}

View File

@ -89,6 +89,7 @@ export type VideoSegment = {
status: string;
error_message: string;
adopted_version: string | null;
adopted_asset?: string | null;
};
export type StoryboardVersion = {
@ -107,6 +108,13 @@ export type Timeline = {
resolution: string;
duration_seconds: number;
clips: Array<{ id: string; asset: string; sort_order: number; start_ms: number; duration_ms: number }>;
subtitle_tracks?: Array<{
id: string;
content: Array<{ start_ms: number; text: string }>;
style?: Record<string, unknown>;
enabled: boolean;
}>;
bgm_tracks?: Array<{ id: string; asset: string; volume: number; start_ms: number }>;
export_jobs?: Array<{
id: string;
status: string;

View File

@ -0,0 +1,39 @@
// 抓图:pipeline 五阶段真数据 + 商品详情 + ai-tools(用 demo 项目/商品)
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = "http://127.0.0.1:5180";
const API = "http://127.0.0.1:8010";
const OUT = process.argv[2] || "shots-pipeline";
mkdirSync(OUT, { recursive: true });
const tok = (await (await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) })).json()).token;
const projs = (await (await fetch(`${API}/api/projects/`, { headers: { Authorization: `Token ${tok}` } })).json()).results;
const demo = projs.find((p) => p.name.startsWith("演示")) || projs[0];
const prods = (await (await fetch(`${API}/api/products/`, { headers: { Authorization: `Token ${tok}` } })).json()).results;
const prod = prods.find((p) => p.title.includes("补水")) || prods[0];
console.log("demo project", demo?.id, "| product", prod?.id);
const shots = [
["pipeline-stage2", `/pipeline/${demo.id}?st=2#stage-2`],
["pipeline-stage3", `/pipeline/${demo.id}?st=3#stage-3`],
["pipeline-stage4", `/pipeline/${demo.id}?st=4#stage-4`],
["pipeline-stage5", `/pipeline/${demo.id}?st=5#stage-5`],
["product-detail", `/products/${prod.id}`],
["image-optimize", `/image-optimize`]
];
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok);
const page = await ctx.newPage();
page.on("pageerror", (e) => console.error(" pageerror:", e.message));
for (const [name, route] of shots) {
try {
await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(1600);
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
console.log("shot", name);
} catch (e) {
console.error("FAIL", name, e.message);
}
}
await browser.close();
console.log("DONE ->", OUT);