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:
parent
603584b46b
commit
099bf0e6aa
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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;
|
||||
|
||||
39
core/qa/visual-parity/shot-pipeline.mjs
Normal file
39
core/qa/visual-parity/shot-pipeline.mjs
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user