Files
yingqing/core/frontend/src/routes/dashboard.tsx
T

264 lines
9.8 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 { 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>
);
}