视频/工作台/商品库(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>
123 lines
9.2 KiB
TypeScript
123 lines
9.2 KiB
TypeScript
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>;
|
||
}
|