Files
yingqing/core/frontend/src/routes/dashboard.tsx
T
zycandClaude Opus 4.8 ffada7caab fix: 测试清单(8) 新一轮 — ZWQ/PMC/设置/团队 多项修复
视频/工作台/商品库(ZWQ):
- row19 视频阶段移除已取消的【上传视频】按钮 (pipeline.tsx)
- row20 工作台统计栏(总项目/进行中/成片)点击跳转视频项目对应 tab (dashboard/projects/route-config/App)
- row21 最新项目跳转改用真实 projectId, 不再跳到最近打开项目 (dashboard.tsx)
- row22+PMC28 网格/列表视图模式持久化(localStorage), 跨菜单记住用户选择 (projects/ai-tools + 新 hook use-view-mode.ts)
- row24 已有三视图的演员/模特直接复用, 不再强制点AI生成三视图 (pipeline.tsx)

图片生成/设置(PMC):
- row27 任务中心: 6个进行中状态补全归类, 不再把生成中误判为失败栏 (stage-config.ts)
- row29 头像上传500: avatar_url 存公读直链替代超长预签名URL(URLField max_length=200溢出) (accounts/views.py, 需部署)
- row30 显示名保存后回显: 改读 first_name 回退 username, 修刷新仍旧名 (settings.tsx)
- row31/32 隐藏【创作默认】【显示】两个设置模块 (settings.tsx)

平台套图(YYX):
- row3 图片预览铺满占位 (ai-tools-page.css)

团队:
- PMC25 充值/邀请成员/设置月限额三弹窗禁止点遮罩误关 (team.tsx)

含平台套图线上提示词优化(services.py/views.py/api.ts, 前序会话遗留一并提交)
构建: tsc --noEmit=0, npm run build=0 (1755模块); 后端 py_compile OK
注: row29 头像/后端项需部署生效

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:45:29 +08:00

123 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import type { BillingSummary, Product, Project } from "../types";
import type { NavigateFn } from "./route-config";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { money, stageMeta, statusPill } from "./stage-config";
import { IconKitSvg } from "../components/IconKitSvg";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
// 工作台「最近项目」进度条:4 段(与视频项目菜单一致)
// script→1 / base_assets→2 / storyboard→3 / video|export→4
// completed:全 done(绿);failed:当前格 fail(红),之前 done;其余:之前 done,当前 cur(橙闪)
const DASH_STAGE_TOTAL = 4;
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
function dashProgClass(project: Project, i: number, no: number): string {
if (project.status === "completed") return i <= no ? "done" : "";
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
}
function DashProgress({ project }: { project: Project }) {
const no = project.status === "completed"
? DASH_STAGE_TOTAL
: (DASH_STAGE_NO[project.current_stage] || 1);
return (
<div className="prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
);
}
export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate, onCreateProduct }: {
products: Product[];
projects: Project[];
productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示
projectTotal?: number;
billing: BillingSummary | null;
userName?: string;
loading?: boolean;
navigate: NavigateFn;
// YYX#11:工作台点「新建商品」直接弹抽屉(复用商品库同一组件),而非跳商品库
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
}) {
// YYX#11:新建商品抽屉开关
const [createDrawer, setCreateDrawer] = useState(false);
const projCount = projectTotal ?? projects.length;
const prodCount = productTotal ?? products.length;
// 资产总数:走轻量 summary 接口(各 tab 计数之和),不再吃全局 assets 全量数组
const [assetCount, setAssetCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.assetSummary()
.then((c) => { if (alive) setAssetCount(Object.values(c).reduce((a, b) => a + b, 0)); })
.catch(() => {});
return () => { alive = false; };
}, []);
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
// 日期 mono:`// 06.19 · 周四`(对齐 V1 mono 注释风,补零到两位)
const now = new Date();
const pad2 = (n: number) => String(n).padStart(2, "0");
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
const dateMono = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())} · 周${weekdays[now.getDay()]}`;
// 「总项目」delta = 本月新建数(真实,按 created_at 算)。
// 但 projects 仅首页分页数据;当存在未加载的项目(projCount > 已加载)时,本月数会少算 → 宁可不显示也不显假值。
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
const newThisMonth = projects.filter((p) => p.created_at && new Date(p.created_at).getTime() >= monthStart).length;
const allProjectsLoaded = projects.length >= projCount;
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
// 余额 mini progress:真实冻结占比 = 冻结 /(可用 + 冻结);无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」,改显真实「冻结 ¥X / 共 ¥Y」。
const balanceNum = Number(billing?.account.balance ?? 0);
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
const fundsTotal = balanceNum + reservedNum;
const reservedPct = fundsTotal > 0 ? Math.min(100, Math.round((reservedNum / fundsTotal) * 100)) : 0;
return (
<>
<div className="page-head">
<div>
<h1>欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}</h1>
<div className="sub"><span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => (onCreateProduct ? setCreateDrawer(true) : navigate("products"))}>
<IconKitSvg name="productPlus" size={16} />
新建商品
</button>
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<IconKitSvg name="clapperboard" size={16} />
新建项目
</button>
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl">余额 <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub">冻结 {money(reservedNum)} / 共 {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2>最近项目</h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] →</button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count} 镜` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm">继续</span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2>快捷入口</h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="商品库" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="资产库" desc={`${assetCount ?? "…"} 资产`} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="充值" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="所有项目" desc={`${projCount} 个`} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2>提示</h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong>扣费规则</strong>生成失败、超时、用户重跑 — 均不扣费。仅在你点 <span className="mono">[ 确认通过 ]</span> 时按 token 实际结算。</div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
<ProductCreateDrawer
open={createDrawer}
close={() => setCreateDrawer(false)}
onCreate={onCreateProduct}
onCreated={() => { setCreateDrawer(false); navigate("products"); }}
/>
)}
</>
);
}
export function KpiStat({ label, badge, value, delta, onClick }: { label: string; badge: string; value: string | number; delta?: string; onClick?: () => void }) {
const inner = <><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</>;
// 可点的统计块复用现有 .stat 类(与余额块一致),不新造样式/色值。
if (onClick) return <button className="stat" type="button" onClick={onClick}>{inner}</button>;
return <div className="stat">{inner}</div>;
}
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
return <a className="shortcut" onClick={onClick}><div className="ic"><IconKitSvg name={name} size={16} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></a>;
}