feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying

Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-09 14:46:42 +08:00
co-authored by Claude Opus 4.8
parent 92826dec14
commit 0873e724bf
29 changed files with 2393 additions and 423 deletions
+77 -26
View File
@@ -1,7 +1,15 @@
import { useState } from "react";
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
import { useEffect, useState } from "react";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
type TrendRange = "day" | "week" | "month";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 ¥", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 ¥", totalLabel: "6 月合计", avgLabel: "月均" }
};
type Tab = "overview" | "by-project" | "by-member" | "bills";
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
@@ -11,16 +19,17 @@ const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: num
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
];
const STAGES: Array<{ k: string; color: string }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)" },
{ k: "故事板(image-2)", color: "var(--accent-forest)" },
{ k: "基础资产", color: "var(--black-alpha-56)" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)" }
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)", bucket: "video" },
{ k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" },
{ k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
];
export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharge }: {
export function AccountPage({ billing, ledgers, trend, projects, teamMembers, onRecharge }: {
billing: BillingSummary | null;
ledgers: Ledger[];
trend: BillingTrend | null;
projects: Project[];
teamMembers: TeamMember[];
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
@@ -46,6 +55,26 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 消费趋势(日/周/月可切)+ 按阶段 / 按项目分布 —— 全部来自真实 CHARGE 流水
// day 用首屏 prop;week/month 切换时按真接口拉对应区间(缺接口已补 ?range=)
const [range, setRange] = useState<TrendRange>("day");
const [rangeTrend, setRangeTrend] = useState<BillingTrend | null>(null);
useEffect(() => {
if (range === "day") { setRangeTrend(null); return; }
let alive = true;
api.billingTrend(range).then((data) => { if (alive) setRangeTrend(data); }).catch(() => {});
return () => { alive = false; };
}, [range]);
const activeTrend = range === "day" ? trend : rangeTrend;
const meta = RANGE_META[range];
const daily = activeTrend?.daily ?? [];
const peak = Number(activeTrend?.peak || 0);
const total14 = Number(activeTrend?.total_14d ?? used);
const avgValue = daily.length ? total14 / daily.length : 0;
const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used;
const projectSpend = (id: string) => Number(trend?.by_project[id] || 0);
return (
<section className="account-page">
<div className="page-head">
@@ -137,32 +166,51 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
<div className="pane trend-pane">
<div className="trend-head">
<h3></h3>
<span className="sub">// 近 14 天 · 单位 ¥</span>
<span className="sub">{meta.sub}</span>
<span className="spacer"></span>
<button className="chip active" type="button"></button>
<button className="chip" type="button"></button>
<button className="chip" type="button"></button>
{(["day", "week", "month"] as TrendRange[]).map((r) => (
<button key={r} className={`chip${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
))}
</div>
<div className="trend-chart">
<div className="bars"></div>
<div className="x-axis"></div>
<div className="bars">
{daily.map((d) => {
const amt = Number(d.amount);
const h = peak > 0 ? Math.max(amt > 0 ? 4 : 0, (amt / peak) * 100) : 0;
const isPeak = peak > 0 && amt === peak;
return (
<div className={`bar${isPeak ? " peak" : ""}`} key={d.date} title={`${d.label} · ${money(amt)}`}>
<span style={{ height: `${h}%` }} />
</div>
);
})}
</div>
<div className="x-axis">
{daily.map((d, i) => (
<span key={d.date}>{i % 2 === 0 ? d.label : ""}</span>
))}
</div>
</div>
<div className="trend-foot">
<div className="item"><span className="k">14 </span><span className="v">{money(used)}</span></div>
<div className="item"><span className="k"></span><span className="v">{money(used / 14)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(used)}</span></div>
<div className="item"><span className="k">{meta.totalLabel}</span><span className="v">{money(total14)}</span></div>
<div className="item"><span className="k">{meta.avgLabel}</span><span className="v">{money(avgValue)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(peak)}</span></div>
</div>
</div>
<div className="pane stage-pane">
<h3></h3>
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
{STAGES.map((s) => (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(0)}</span></div>
<div className="usage-bar"><span style={{ width: "0%", background: s.color }} /></div>
</div>
))}
{STAGES.map((s) => {
const amt = Number(trend?.by_stage[s.bucket] || 0);
const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0;
return (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(amt)}</span></div>
<div className="usage-bar"><span style={{ width: `${w}%`, background: s.color }} /></div>
</div>
);
})}
<div className="total"><span></span><span className="v">{money(used)}</span></div>
</div>
</div>
@@ -208,9 +256,12 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
<table className="billing-table">
<thead><tr><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className="zero">{money(0)}</td></tr>
))}
{projects.map((p) => {
const spend = projectSpend(p.id);
return (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className={spend > 0 ? "neg" : "zero"}>{money(spend)}</td></tr>
);
})}
</tbody>
</table>
</div>