264 lines
9.8 KiB
TypeScript
264 lines
9.8 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import {
|
||
ArrowRight,
|
||
ChevronRight,
|
||
FolderKanban,
|
||
Replace,
|
||
ScanSearch,
|
||
WandSparkles,
|
||
} from "lucide-react";
|
||
import type { BillingSummary, Product, Project } from "../types";
|
||
import type { NavigateFn, Page } from "./route-config";
|
||
|
||
type DashTab = "all" | "wip" | "done";
|
||
type EntryTone = "primary" | "subtle";
|
||
|
||
const CREATE_GROUPS: Array<{
|
||
label: string;
|
||
hint: string;
|
||
cards: Array<{
|
||
title: string;
|
||
desc: string;
|
||
tone: EntryTone;
|
||
page: Page;
|
||
icon: "wand" | "folder" | "scan" | "replace";
|
||
}>;
|
||
}> = [
|
||
{
|
||
label: "从商品开始",
|
||
hint: "围绕商品卖点生成完整带货内容",
|
||
cards: [
|
||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "projectWizard", icon: "wand" },
|
||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||
],
|
||
},
|
||
{
|
||
label: "从参考视频开始",
|
||
hint: "复用成熟视频的镜头表达与节奏",
|
||
cards: [
|
||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "freeCreate", icon: "scan" },
|
||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
|
||
],
|
||
},
|
||
];
|
||
|
||
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 ? "warn" : "";
|
||
}
|
||
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(" / ");
|
||
}
|
||
|
||
function EntryIcon({ name }: { name: "wand" | "folder" | "scan" | "replace" }) {
|
||
if (name === "folder") return <FolderKanban />;
|
||
if (name === "scan") return <ScanSearch />;
|
||
if (name === "replace") return <Replace />;
|
||
return <WandSparkles />;
|
||
}
|
||
|
||
export function Dashboard({
|
||
products,
|
||
projects,
|
||
productTotal: _productTotal,
|
||
projectTotal,
|
||
billing: _billing,
|
||
userName,
|
||
loading: _loading = false,
|
||
navigate,
|
||
}: {
|
||
products: Product[];
|
||
projects: Project[];
|
||
productTotal?: number;
|
||
projectTotal?: number;
|
||
billing: BillingSummary | null;
|
||
userName?: string;
|
||
loading?: boolean;
|
||
navigate: NavigateFn;
|
||
}) {
|
||
const [tab, setTab] = useState<DashTab>("all");
|
||
const projCount = projectTotal ?? projects.length;
|
||
const completed = projects.filter((project) => project.status === "completed").length;
|
||
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
|
||
const counts = {
|
||
all: projCount,
|
||
wip: running,
|
||
done: completed,
|
||
};
|
||
|
||
const now = new Date();
|
||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||
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 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, 7);
|
||
}, [projects, tab]);
|
||
|
||
return (
|
||
<section className="dashboard-page">
|
||
<section className="welcome">
|
||
<h1>欢迎回来{greetName ? `,${greetName}` : ""}</h1>
|
||
<p>{dateLabel} · 你有{running}个项目正在进行中</p>
|
||
</section>
|
||
|
||
<section className="creation" aria-labelledby="creationTitle">
|
||
<div className="creation-head">
|
||
<span className="section-label" id="creationTitle">开始创作</span>
|
||
</div>
|
||
<div className="creation-groups">
|
||
{CREATE_GROUPS.map((group) => (
|
||
<div className="creation-group-block" key={group.label}>
|
||
<div className="creation-group-label">
|
||
<strong>{group.label}</strong>
|
||
<span>{group.hint}</span>
|
||
</div>
|
||
<div className="creation-entry-pair">
|
||
{group.cards.map((card) => (
|
||
<button
|
||
key={card.title}
|
||
className={`entry entry-${card.tone}`}
|
||
type="button"
|
||
onClick={() => navigate(card.page)}
|
||
>
|
||
<EntryIcon name={card.icon} />
|
||
<span className="entry-copy">
|
||
<strong>{card.title}</strong>
|
||
<small>{card.desc}</small>
|
||
</span>
|
||
<ArrowRight />
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="workbench-section-divider"><span>项目概览</span></div>
|
||
|
||
<section className="status-summary" aria-label="项目概览">
|
||
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "all" })}>
|
||
<span>总项目</span>
|
||
<strong>{projCount}</strong>
|
||
</button>
|
||
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
|
||
<span>进行中</span>
|
||
<strong>{running}</strong>
|
||
</button>
|
||
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "done" })}>
|
||
<span>成片</span>
|
||
<strong>{completed}</strong>
|
||
</button>
|
||
<button className="summary-item" type="button" onClick={() => navigate("projects")}>
|
||
<span>本月</span>
|
||
<strong className="positive">+{newThisMonth}</strong>
|
||
</button>
|
||
</section>
|
||
|
||
<section className="projects">
|
||
<div className="projects-head">
|
||
<h2>我的项目</h2>
|
||
<button className="text-action" type="button" onClick={() => navigate("projects")}>
|
||
<span>查看全部项目</span>
|
||
<ChevronRight />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="filters" role="tablist" aria-label="项目筛选">
|
||
{DASH_TABS.map((item) => (
|
||
<button
|
||
key={item.filter}
|
||
className={`filter${tab === item.filter ? " active" : ""}`}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={tab === item.filter}
|
||
onClick={() => setTab(item.filter)}
|
||
>
|
||
{item.label} {counts[item.filter]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{listed.length === 0 ? (
|
||
<div className="empty-state">当前筛选下暂无展示项目</div>
|
||
) : (
|
||
<div className="project-list">
|
||
{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="project-card"
|
||
data-status={dashBucket(project)}
|
||
onClick={openProject}
|
||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||
role="button"
|
||
tabIndex={0}
|
||
>
|
||
<div className="product-thumb">
|
||
{cover ? <img src={cover} alt="" /> : <span className="ph-frame">9:16</span>}
|
||
</div>
|
||
<div className="project-info">
|
||
<h3>{project.name}</h3>
|
||
<p>{dashCardMeta(project, productTitle(project.product))}</p>
|
||
</div>
|
||
<div className="stage">
|
||
<span className="stage-label">当前阶段</span>
|
||
<strong>{dashStageLabel(project)}</strong>
|
||
<div className="progress" aria-label="项目进度">
|
||
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
|
||
<span key={i} className={`segment ${dashProgClass(project, i, no)}`} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="continue-button"
|
||
type="button"
|
||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||
>
|
||
{project.status === "completed" ? "查看" : "继续"}
|
||
</button>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</section>
|
||
);
|
||
}
|