修改部份ui
This commit is contained in:
@@ -1,138 +1,243 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { CSSProperties } 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 type { NavigateFn, Page } from "./route-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 };
|
||||
type CreateTone = "blue-a" | "blue-b" | "light";
|
||||
type DashTab = "all" | "wip" | "done";
|
||||
|
||||
const CREATE_GROUPS: Array<{
|
||||
label: string;
|
||||
cards: Array<{
|
||||
title: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
tone: CreateTone;
|
||||
page: Page;
|
||||
}>;
|
||||
}> = [
|
||||
{
|
||||
label: "从商品开始",
|
||||
cards: [
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", icon: "wand", tone: "blue-a", page: "projectWizard" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", icon: "clapperboard", tone: "blue-b", page: "projectWizard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "从参考视频开始",
|
||||
cards: [
|
||||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", icon: "scan", tone: "light", page: "freeCreate" },
|
||||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", icon: "swap", tone: "light", page: "freeCreate" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DASH_STAGE_TOTAL = 5;
|
||||
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
|
||||
const DASH_TABS: Array<{ filter: DashTab; label: string }> = [
|
||||
{ filter: "all", label: "全部" },
|
||||
{ filter: "wip", label: "进行中" },
|
||||
{ filter: "done", label: "已成片" },
|
||||
];
|
||||
|
||||
function dashBucket(project: Project): DashTab {
|
||||
return project.status === "completed" ? "done" : "wip";
|
||||
}
|
||||
function dashStageNo(project: Project) {
|
||||
return project.status === "completed" ? DASH_STAGE_TOTAL : (DASH_STAGE_NO[project.current_stage] || 1);
|
||||
}
|
||||
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>
|
||||
);
|
||||
function dashStageLabel(project: Project): string {
|
||||
if (project.status === "failed") return "失败";
|
||||
const map: Record<string, string> = {
|
||||
script: "脚本",
|
||||
base_assets: "资产创建",
|
||||
storyboard: "故事板",
|
||||
video: "视频生成",
|
||||
export: "视频生成",
|
||||
};
|
||||
return map[project.current_stage] || "视频生成";
|
||||
}
|
||||
function dashCardMeta(project: Project, productTitle: string): string {
|
||||
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
|
||||
return ["专业创作", productTitle, shots ? `${shots} 镜` : null].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
// 与「视频项目」共用项目列表返回的商品主图;空值继续走现有 9:16 占位图。
|
||||
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
|
||||
export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate, onCreateProduct }: {
|
||||
export function Dashboard({
|
||||
products,
|
||||
projects,
|
||||
productTotal: _productTotal,
|
||||
projectTotal,
|
||||
billing: _billing,
|
||||
userName,
|
||||
loading: _loading = false,
|
||||
navigate,
|
||||
}: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示
|
||||
productTotal?: number;
|
||||
projectTotal?: number;
|
||||
billing: BillingSummary | null;
|
||||
userName?: string;
|
||||
loading?: boolean;
|
||||
navigate: NavigateFn;
|
||||
// YYX#11:工作台点「新建商品」直接弹抽屉(复用商品库同一组件),而非跳商品库
|
||||
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
|
||||
onCreateProduct?: (payload: unknown) => Promise<Product | null | undefined> | void;
|
||||
}) {
|
||||
// YYX#11:新建商品抽屉开关
|
||||
const [createDrawer, setCreateDrawer] = useState(false);
|
||||
const [tab, setTab] = useState<DashTab>("all");
|
||||
const projCount = projectTotal ?? projects.length;
|
||||
const prodCount = productTotal ?? products.length;
|
||||
// 资产条目总数:与资产库六个 Tab 一致,包含按项目聚合的「视频成品」素材包。
|
||||
const [assetCount, setAssetCount] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.assetSummary(), api.videoPacks()])
|
||||
.then(([c, packs]) => {
|
||||
if (!alive) return;
|
||||
const imageCreations = c.image_creations ?? c.creations ?? 0;
|
||||
setAssetCount((c.tryon || 0) + (c.kits || 0) + imageCreations + (c.video_creations || 0) + packs.length + (c.others || 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 || "商品";
|
||||
const counts = {
|
||||
all: projCount,
|
||||
wip: running,
|
||||
done: completed,
|
||||
};
|
||||
|
||||
// 日期 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 dateLabel = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())}`;
|
||||
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;
|
||||
const greetName = userName ? userName.split("@")[0] : "";
|
||||
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
|
||||
|
||||
const listed = useMemo(() => {
|
||||
const rows = tab === "all" ? projects : projects.filter((project) => dashBucket(project) === tab);
|
||||
return rows.slice(0, 3);
|
||||
}, [projects, tab]);
|
||||
|
||||
// 冻结信息:真实「冻结 X 积分 / 共 Y 积分」,无 budget/已用字段,故不复刻 V1「已用/共」。
|
||||
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
|
||||
const balanceNum = Number(billing?.account.balance ?? 0);
|
||||
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
|
||||
const fundsTotal = balanceNum + reservedNum;
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h1>欢迎回来{userName ? `,${userName.split("@")[0]}` : ""}</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span>
|
||||
<span className="dash-funds-mono" style={{ marginLeft: "auto" }} onClick={() => navigate("account")}>
|
||||
余额 {money(balanceNum)} · 冻结 {money(reservedNum)} / 共 {money(fundsTotal)}
|
||||
</span>
|
||||
<section className="dashboard-page">
|
||||
<header className="dash-hero">
|
||||
<h1>欢迎回来{greetName ? `,${greetName}` : ""}</h1>
|
||||
<p className="dash-hero-sub">
|
||||
{dateLabel} · 你有{running}个项目正在进行中
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="dash-create" aria-label="开始创作">
|
||||
<h2>开始创作</h2>
|
||||
{CREATE_GROUPS.map((group) => (
|
||||
<div className="dash-create-group" key={group.label}>
|
||||
<div className="dash-create-label">{group.label}</div>
|
||||
<div className="dash-create-row">
|
||||
{group.cards.map((card) => (
|
||||
<button
|
||||
key={card.title}
|
||||
className={`dash-create-card ${card.tone}`}
|
||||
type="button"
|
||||
onClick={() => navigate(card.page)}
|
||||
>
|
||||
<span className="dash-create-ic" aria-hidden="true">
|
||||
<IconKitSvg name={card.icon} size={22} strokeWidth={1.7} />
|
||||
</span>
|
||||
<span className="dash-create-copy">
|
||||
<span className="t">{card.title}</span>
|
||||
<span className="d">{card.desc}</span>
|
||||
</span>
|
||||
<span className="dash-create-arrow" aria-hidden="true">
|
||||
<IconKitSvg name="arrowRight" size={18} strokeWidth={1.8} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" onClick={() => (onCreateProduct ? setCreateDrawer(true) : navigate("products"))}>
|
||||
<IconKitSvg name="productPlus" size={16} />
|
||||
新建商品
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="dash-overview" aria-label="项目概览">
|
||||
<div className="dash-overview-h">项目概览</div>
|
||||
<div className="dash-overview-bar">
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "all" })}>
|
||||
<span className="lbl">总项目</span>
|
||||
<span className="v">{projCount}</span>
|
||||
</button>
|
||||
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
|
||||
<IconKitSvg name="clapperboard" size={16} />
|
||||
新建项目
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
|
||||
<span className="lbl">进行中</span>
|
||||
<span className="v">{running}</span>
|
||||
</button>
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "done" })}>
|
||||
<span className="lbl">成片</span>
|
||||
<span className="v">{completed}</span>
|
||||
</button>
|
||||
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects")}>
|
||||
<span className="lbl">本月</span>
|
||||
<span className="v">+{newThisMonth}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats with-corners" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}><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" })} /></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) => {
|
||||
const cover = project.cover_preview_url || "";
|
||||
return <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className={`placeholder thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><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"); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</section>
|
||||
|
||||
<section className="dash-mine" aria-label="我的项目">
|
||||
<div className="dash-mine-head">
|
||||
<h2>我的项目</h2>
|
||||
<button className="dash-mine-all" type="button" onClick={() => navigate("projects")}>
|
||||
查看全部项目 <IconKitSvg name="chevronRight" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dash-mine-pills" role="tablist" aria-label="项目筛选">
|
||||
{DASH_TABS.map((item) => (
|
||||
<button
|
||||
key={item.filter}
|
||||
className={`dash-mine-pill${tab === item.filter ? " active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === item.filter}
|
||||
onClick={() => setTab(item.filter)}
|
||||
>
|
||||
{item.label} {counts[item.filter]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dash-mine-list">
|
||||
{listed.length === 0 ? (
|
||||
<div className="dash-mine-empty">还没有项目,从上面开始创作</div>
|
||||
) : listed.map((project) => {
|
||||
const no = dashStageNo(project);
|
||||
const cover = project.cover_preview_url || "";
|
||||
const openProject = () => navigate("pipeline", { projectId: project.id });
|
||||
return (
|
||||
<article
|
||||
key={project.id}
|
||||
className="dash-proj-card"
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={`placeholder dash-proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}>
|
||||
<span className="ph-frame">9:16</span>
|
||||
</div>
|
||||
<div className="dash-proj-main">
|
||||
<div className="dash-proj-title">{project.name}</div>
|
||||
<div className="dash-proj-sub">{dashCardMeta(project, productTitle(project.product))}</div>
|
||||
</div>
|
||||
<div className="dash-proj-stage">
|
||||
<div className="dash-proj-stage-lbl">当前阶段</div>
|
||||
<div className="dash-proj-stage-name">{dashStageLabel(project)}</div>
|
||||
<div className="dash-proj-prog">
|
||||
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
|
||||
<span key={i} className={dashProgClass(project, i, no)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="dash-proj-cta"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
{project.status === "completed" ? "查看" : "继续"}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user