渐进渲染后,列表在接口返回前会短暂显示「0 SKU / 显示 0」的空状态,易被误读为「没数据」。
新增 SkeletonGrid/SkeletonRows(components/loading),区分「加载中(请求在途且无数据)」与
「加载完确实为空」:
- 全局态页(商品库/视频项目/工作台最近项目):App 传 loading={!dataLoaded},未加载完显示骨架。
- 自取页(资产库/商品详情AI素材/图片生成任务/账单流水):各自 loading 标志,首次拉取时显示骨架/加载行。
- 团队成员有 owner 兜底行(非空)不改。
闸:9 页全绿、功能正常;实测 /products 加载期显示骨架→数据到后渲染真实卡片。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
65 lines
5.6 KiB
TypeScript
65 lines
5.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import type { BillingSummary, Product, Project } from "../types";
|
||
import type { Page } from "./route-config";
|
||
import { api } from "../api";
|
||
import { SkeletonRows } from "../components/loading";
|
||
import { money, stageMeta, statusPill } from "./stage-config";
|
||
import { Progress } from "../components/pipeline-stage";
|
||
import { IconKitSvg } from "../components/IconKitSvg";
|
||
|
||
export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate }: {
|
||
products: Product[];
|
||
projects: Project[];
|
||
productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示
|
||
projectTotal?: number;
|
||
billing: BillingSummary | null;
|
||
userName?: string;
|
||
loading?: boolean;
|
||
navigate: (page: Page) => void;
|
||
}) {
|
||
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 || "商品";
|
||
return (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}</h1>
|
||
<div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div>
|
||
</div>
|
||
<div className="actions">
|
||
<button className="btn" type="button" onClick={() => 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={`↑ 本月 +${Math.max(projCount, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><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: "38%" }} /></div><div className="sub">已冻结 {money(billing?.account.reserved_balance)}</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")}><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 全生 / 4 镜</div></div><Progress status={project.current_stage} /><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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta: string }) {
|
||
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></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>;
|
||
}
|