From 8d88dd7e23b31e32b7db3cb4a635cf3fd959b6b3 Mon Sep 17 00:00:00 2001 From: maomaochong-ai Date: Wed, 26 Aug 2026 23:52:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=A6=96=E9=A1=B5=20-=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=BA=E5=88=9B=E4=BD=9C=E4=BB=AA=E8=A1=A8=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 参考工作台 Dashboard 模板,将 /home 从双态欢迎页重构为创作仪表盘 - 新增欢迎区、统计卡(进行中项目/自由画布/素材资产/本周任务)、创作活跃度柱状图(近 7/14/30 天)、素材构成环形图、最近项目表格(搜索/筛选/排序/分页) - 保留快捷创建与从工作流开始入口;数据来源为项目接口、画布/素材 store 与生成任务 - 移除旧首页样式类,新增 .app-home-dashboard 样式系统;复用现有设计 token - 登记待浏览器验证清单至 pending-test.mdx --- docs/content/docs/progress/pending-test.mdx | 12 + web/src/pages/home/index.tsx | 696 ++++++++++++++------ web/src/styles/globals.css | 386 +++++++++-- 3 files changed, 867 insertions(+), 227 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 88f392d6d..81b110be2 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -569,3 +569,15 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 在制作概览点击剧情、资产、分镜、预演、视频、交付和三个快速入口,确认跳转到正确章节及阶段,真实镜头产物数量与完成状态一致。 - [ ] 在浅色、深色主题和 1280px、1680px 宽度检查镜头卡,确认时长明显、标题与镜头参数不溢出、“已生成”为绿色且选中态仍使用工作台强调色。 - [ ] 在画布不同缩放和平移位置双击空白处,确认新增节点面板直接打开,选择文本、图片、视频和扩展节点后落点接近双击位置;双击节点、弹窗和其他浮层不应触发新增。 +## 工作台首页创作仪表盘改造 + +参考模板 Dashboard 将 `/home` 首页重构为创作仪表盘(欢迎区 + 统计卡 + 活跃度/素材图表 + 最近项目表格),以下场景仍需要浏览器确认: + +- [x] `web` 前端 TypeScript 检查通过;旧首页样式类已随页面重写移除。 +- [ ] 登录用户确认欢迎区显示用户名、主操作跳转 `/projects?create=1`、打开画布按钮在未登录时引导登录。 +- [ ] 确认统计卡数值与项目/画布/素材/任务页面一致:进行中项目、自由画布、素材资产、本周任务。 +- [ ] 确认创作活跃度图表随 7/14/30 天切换刷新,生成任务与画布更新两个系列与任务中心/画布列表一致。 +- [ ] 确认素材构成环形图按类型统计正确,无素材时显示空态文案。 +- [ ] 确认最近项目表格的搜索、状态筛选、排序与分页行为,行点击进入项目概览;无项目时显示创建入口。 +- [ ] 确认快捷创建与工作流面板入口可用,工作流提示显示最近项目阶段与章节完成度。 +- [ ] 验证浅色/深色主题与窄窗口(表格横向滚动、统计卡换行)下可读可操作。 diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx index 3bcdc9605..bb261934e 100644 --- a/web/src/pages/home/index.tsx +++ b/web/src/pages/home/index.tsx @@ -1,15 +1,19 @@ -import { useMemo, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { App, Button } from "antd"; -import { ArrowRight, Bot, Clapperboard, FolderKanban, Images, LayoutGrid, ListChecks, Plus, Sparkles } from "lucide-react"; +import { App, Button, Input, Pagination, Segmented, Select } from "antd"; +import { ArrowRight, FolderKanban, Images, LayoutGrid, ListTodo, Plus, Search, Sparkles } from "lucide-react"; import { Link, useNavigate } from "react-router"; +import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts"; -import { CanvasProjectCard } from "@/components/canvas/canvas-project-card"; +import { resolveCanvasStylePreset, resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal"; import { WorkspaceErrorState, WorkspaceLoadingState } from "@/components/layout/workspace-state"; import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon"; -import { projectDetailStage, projectSummaryCompletion } from "@/lib/project-workbench"; -import { getProject, listProjects, type ProjectSummary } from "@/services/api/projects"; +import { parseStyleProfile } from "@/lib/canvas/style-profile"; +import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench"; +import { listProjects, type ProjectSummary } from "@/services/api/projects"; +import { listGenerationTasks } from "@/services/api/task-center"; import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync"; +import { useAssetStore, type AssetKind } from "@/stores/use-asset-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -20,29 +24,49 @@ const workflow = [ { title: "检查结果", description: "比较版本、处理失败并整理导出" }, ]; +const sourceTypeLabels: Record = { + blank: "空白开始", + novel: "导入小说", + text: "粘贴文本", +}; + +const ASSET_KINDS: Array<{ key: AssetKind; label: string }> = [ + { key: "image", label: "图片" }, + { key: "video", label: "视频" }, + { key: "audio", label: "音频" }, + { key: "text", label: "文本" }, + { key: "entity", label: "实体" }, + { key: "model", label: "模型" }, +]; + +const CHART_ACCENT = "var(--workspace-accent)"; +const CHART_MUTED = "color-mix(in srgb, var(--workspace-accent) 30%, transparent)"; + export default function IndexPage() { const { message } = App.useApp(); const navigate = useNavigate(); const canvasHydrated = useCanvasStore((state) => state.hydrated); const canvasProjects = useCanvasStore((state) => state.projects); + const assets = useAssetStore((state) => state.assets); const user = useUserStore((state) => state.user); const userHydrated = useUserStore((state) => state.hydrated); const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); - const domainProjectsQuery = useQuery({ queryKey: ["projects"], queryFn: () => listProjects(), enabled: Boolean(user && shortDramaEnabled) }); - const domainProjects = useMemo( - () => [...(domainProjectsQuery.data?.projects || [])].sort((left, right) => right.project.updatedAt.localeCompare(left.project.updatedAt)), - [domainProjectsQuery.data], - ); - const activeProject = domainProjects.find(({ project }) => project.status !== "archived") || domainProjects[0]; - const activeProjectQuery = useQuery({ - queryKey: ["project", activeProject?.project.id], - queryFn: () => getProject(activeProject!.project.id), - enabled: Boolean(user && shortDramaEnabled && activeProject?.project.id), + const taskCenterEnabled = useUserStore((state) => state.features.taskCenterEnabled); + + const domainProjectsQuery = useQuery({ + queryKey: ["projects"], + queryFn: () => listProjects(), + enabled: Boolean(user && shortDramaEnabled), }); - const recentIndependentCanvases = useMemo( - () => canvasProjects.filter((project) => !project.projectId).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).slice(0, 3), - [canvasProjects], - ); + const domainProjects = useMemo(() => [...(domainProjectsQuery.data?.projects || [])].sort((left, right) => right.project.updatedAt.localeCompare(left.project.updatedAt)), [domainProjectsQuery.data]); + + // 仪表盘需要生成记录做活跃度与任务统计;后端不可用时降级为空数据,不影响其他区块。 + const tasksQuery = useQuery({ + queryKey: ["home-dashboard-tasks"], + queryFn: () => listGenerationTasks(300).catch(() => []), + enabled: Boolean(user && taskCenterEnabled), + }); + const tasks = useMemo(() => tasksQuery.data || [], [tasksQuery.data]); const createIndependentCanvas = () => { if (!canvasHydrated) return; @@ -59,26 +83,21 @@ export default function IndexPage() { const loadingUserWorkspace = !userHydrated || (Boolean(user && shortDramaEnabled) && domainProjectsQuery.isLoading); return (
-
+
{loadingUserWorkspace ? ( - + ) : user && shortDramaEnabled && domainProjectsQuery.isError ? ( void domainProjectsQuery.refetch()} /> - ) : shortDramaEnabled && activeProject ? ( - ) : ( - )}
@@ -86,187 +105,488 @@ export default function IndexPage() { ); } -function ReturningWorkspace({ summary, detail, recentProjects, recentIndependentCanvases, onCreateIndependentCanvas }: { - summary: ProjectSummary; - detail?: Awaited>; - recentProjects: ProjectSummary[]; - recentIndependentCanvases: ReturnType["projects"]; - onCreateIndependentCanvas: () => void; -}) { - const stage = detail ? projectDetailStage(detail) : { label: "进行中", detail: "读取项目进度" }; - const completion = projectSummaryCompletion(summary); - return ( - <> -
-
-
-
-

继续创作

-

回到最近工作,或先处理阻塞制作的事项。

-
-
-
- - 创建项目 -
-
-
- -
- } title="创建短剧项目" description="从空白、小说或文本开始建立章节流程。" action="创建" href="/projects?create=1" /> - } title="打开自由画布" description="适合快速试图、提示词实验和自由创作。" action="打开" onClick={onCreateIndependentCanvas} /> - } title="进入素材库" description="整理角色、场景、画风和媒体资产。" action="进入" href="/assets" /> -
- -
-
-
-

最近项目

按最近更新时间排列

- 查看全部 -
-
- {recentProjects.map((project, index) => 0} />)} -
-
- -
-
-

最近自由画布

不属于项目的自由创作空间

- 管理画布 -
- {recentIndependentCanvases.length ? ( -
- {recentIndependentCanvases.slice(0, 2).map((project) => )} -
- ) : ( - - )} -
-
- -
-
-

从工作流开始

整理故事 → 确认设定 → 制作镜头 → 检查结果。

- {stage.label} · {completion}% -
-
- {workflow.map((item, index) => ( -
-
- 0{index + 1} - {index < workflow.length - 1 ?
-

{item.title}

-

{item.description}

-
- ))} -
-
- - ); -} - -function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependentCanvases, onCreateIndependentCanvas, shortDramaEnabled }: { - authenticated: boolean; - canvasHydrated: boolean; - recentIndependentCanvases: ReturnType["projects"]; - onCreateIndependentCanvas: () => void; +function HomeDashboard({ + user, + shortDramaEnabled, + domainProjects, + canvasProjects, + assets, + tasks, + canvasHydrated, + onCreateIndependentCanvas, +}: { + user: ReturnType["user"]; shortDramaEnabled: boolean; + domainProjects: ProjectSummary[]; + canvasProjects: ReturnType["projects"]; + assets: ReturnType["assets"]; + tasks: Awaited>; + canvasHydrated: boolean; + onCreateIndependentCanvas: () => void; }) { - const projectHref = authenticated ? "/projects?create=1" : `/login?next=${encodeURIComponent("/projects?create=1")}`; + const hasProjects = Boolean(domainProjects.length); + const displayName = user?.displayName || user?.username || "创作者"; + + const activeProjects = useMemo(() => domainProjects.filter(({ project }) => project.status !== "archived"), [domainProjects]); + const archivedCount = domainProjects.length - activeProjects.length; + const totalUnits = useMemo(() => activeProjects.reduce((sum, row) => sum + row.unitCount, 0), [activeProjects]); + const activeProject = activeProjects[0]; + + const independentCanvases = useMemo(() => canvasProjects.filter((project) => !project.projectId), [canvasProjects]); + const latestCanvasUpdate = useMemo(() => canvasProjects.reduce((latest, project) => (latest === null || project.updatedAt > latest ? project.updatedAt : latest), null), [canvasProjects]); + + const weekWindow = Date.now() - 7 * 24 * 60 * 60 * 1000; + const weekTasks = useMemo(() => tasks.filter((task) => new Date(task.createdAt).getTime() >= weekWindow), [tasks, weekWindow]); + const weekSucceeded = useMemo(() => weekTasks.filter((task) => task.status === "succeeded").length, [weekTasks]); + const weekFailed = useMemo(() => weekTasks.filter((task) => task.status === "failed" || task.status === "cancelled").length, [weekTasks]); + const weekRunning = useMemo(() => weekTasks.filter((task) => task.status === "queued" || task.status === "running").length, [weekTasks]); + + const assetKindCounts = useMemo(() => { + const counts: Partial> = {}; + for (const asset of assets) counts[asset.kind] = (counts[asset.kind] || 0) + 1; + return counts; + }, [assets]); + + const [rangeDays, setRangeDays] = useState(14); + const activityData = useMemo( + () => + buildActivityBuckets( + rangeDays, + tasks.map((task) => new Date(task.createdAt).getTime()), + canvasProjects.map((project) => new Date(project.updatedAt).getTime()), + ), + [rangeDays, tasks, canvasProjects], + ); + const assetChartEntries = useMemo(() => assetChartData(assets), [assets]); + const assetTotal = useMemo(() => assets.length, [assets]); + + const [keyword, setKeyword] = useState(""); + const [status, setStatus] = useState<"all" | "active" | "archived">("all"); + const [sort, setSort] = useState<"updated" | "progress" | "name">("updated"); + const [page, setPage] = useState(1); + const rows = useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase(); + return domainProjects + .filter(({ project }) => status === "all" || project.status === status) + .filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description || ""} ${projectStyleTitle(project)}`.toLowerCase().includes(normalizedKeyword)) + .sort((left, right) => { + if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN"); + if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left); + return right.project.updatedAt.localeCompare(left.project.updatedAt); + }); + }, [domainProjects, keyword, sort, status]); + const pageSize = 5; + const safePage = Math.min(page, Math.max(1, Math.ceil(rows.length / pageSize))); + const pageRows = rows.slice((safePage - 1) * pageSize, safePage * pageSize); + + const projectHref = user ? "/projects?create=1" : `/login?next=${encodeURIComponent("/projects?create=1")}`; + return ( <> -
-
影策
-

把一个故事推进到可交付的镜头

-

从章节、角色和参考图开始,逐步生成分镜、视频和可复用资产。需要自由探索时,也可以先打开一张自由画布。

-
- {shortDramaEnabled ? 创建项目 : null} - +
+
+ + + 影策 · AI 影视与短剧创作工作台 + +

{hasProjects ? `欢迎回来,${displayName}` : `欢迎使用影策,${displayName}`}

+

{hasProjects ? "回到最近制作,或从一句话故事开始一部新的短剧。" : "从一个故事开始:整理章节、确认设定、制作镜头,直到可交付的结果。"}

+
+ {shortDramaEnabled ? ( + + + 开始创作 + + ) : null} + +
+
+ +
+ {shortDramaEnabled ? ( + } label="进行中项目" value={activeProjects.length} hint={activeProjects.length ? `共 ${totalUnits} 章 · ${archivedCount} 个已归档` : "创建项目开始制作"} to="/projects" /> + ) : null} + } label="自由画布" value={independentCanvases.length} hint={latestCanvasUpdate ? `最近更新 ${formatRelativeTime(latestCanvasUpdate)}` : "新建独立创作空间"} to="/canvas" /> + } label="素材资产" value={assets.length} hint={assetSummaryHint(assetKindCounts)} to="/assets" /> + } label="本周任务" value={weekTasks.length} hint={weekTasks.length ? `成功 ${weekSucceeded} · 失败 ${weekFailed} · 进行中 ${weekRunning}` : "本周还没有生成任务"} to="/tasks" />
-
-

从故事到结果

每一步都保留输入、版本和生成记录,可以随时返回调整。

-
- {workflow.map((item, index) => ( -
-
- 0{index + 1} - {index < workflow.length - 1 ?
-

{item.title}

-

{item.description}

+
+
+
+
+

创作活跃度

+

按天统计生成任务与画布更新

- ))} -
-
- -
-
-

两种开始方式

-
- {shortDramaEnabled ? } title="项目" description="适合短剧、故事板和多章节制作。集中管理章节、资产、画布与进度。" action="创建项目" href={projectHref} /> : null} - } title="自由画布" description="适合快速试图、提示词实验和不需要章节流程的自由创作。" action="打开画布" onClick={onCreateIndependentCanvas} /> + setRangeDays(value as number)} + /> +
+
+ {activityData.some((item) => item.tasks > 0 || item.canvases > 0) ? ( + + + + 14 ? 4 : rangeDays > 7 ? 2 : 0} + tick={{ fill: "var(--foreground)", opacity: 0.45, fontSize: 11 }} + /> + + + + + + + ) : ( +
还没有生成记录,去画布或项目开始第一次创作。
+ )}
-
-

创作过程中

-
- } text="图片、视频和音频结果可以继续生成变体或接入下一步。" /> - } text="Agent 读取你选择的章节、节点和参考资料,再执行画布操作。" /> - } text="任务、失败原因和用量记录会保留,便于恢复和重试。" /> + +
+
+
+

素材构成

+

按类型统计当前素材库

+
+
+
+ {assetTotal ? ( +
+
+ + + + {assetChartEntries.map((entry) => ( + + ))} + + + + +
+ {assetTotal} + 项素材 +
+
+
    + {assetChartEntries.map((entry) => ( +
  • + + {entry.name} + {entry.value} +
  • + ))} +
+
+ ) : ( +
还没有素材资产,生成的图片、视频和音频会沉淀在这里。
+ )}
- {recentIndependentCanvases.length ? ( -
-

继续自由画布

查看全部
-
{recentIndependentCanvases.map((project) => )}
-
- ) : null} +
+
+
+
+

最近项目

+

按更新时间排列,支持搜索、筛选与排序

+
+ + 查看全部 + + +
+
+ } + value={keyword} + placeholder="搜索项目名称或画风" + onChange={(event) => { + setKeyword(event.target.value); + setPage(1); + }} + aria-label="搜索项目" + /> + { + setSort(value as "updated" | "progress" | "name"); + setPage(1); + }} + options={[ + { label: "最近更新", value: "updated" }, + { label: "章节进度", value: "progress" }, + { label: "项目名称", value: "name" }, + ]} + /> +
+
+
+ + {rows.length ? ( + pageRows.map((row) => ) + ) : ( +
+ {domainProjects.length ? ( + <> + +

没有匹配的项目

+

调整搜索词或状态筛选后再试。

+ + ) : shortDramaEnabled ? ( + <> + +

创建第一个故事项目

+

项目会集中保存章节、项目画布、角色场景和制作进度,从这里开始一部短剧。

+ + + 创建项目 + + + ) : ( + <> + +

项目功能尚未启用

+

可以从自由画布开始创作,或联系管理员开启短剧项目。

+ + )} +
+ )} +
+
+
+ + 共 {rows.length} 个项目{rows.length ? `,每页 ${pageSize} 条` : ""} + + {rows.length > pageSize ? : null} +
+
+ +
+
+
+
+

快捷创建

+

常用创作入口

+
+
+
+ {shortDramaEnabled ? } title="创建短剧项目" description="从空白、小说或文本建立章节流程" href={projectHref} /> : null} + } title="打开自由画布" description="适合快速试图与提示词实验" onClick={onCreateIndependentCanvas} disabled={!canvasHydrated} /> + } title="进入素材库" description="整理角色、场景与媒体资产" href="/assets" /> +
+
+ +
+
+
+

从工作流开始

+

整理故事 → 确认设定 → 制作镜头 → 检查结果

+
+
+
+
+ {workflow.map((item, index) => ( +
+ 0{index + 1} + + {item.title} + {item.description} + +
+ ))} +
+

{activeProject ? `${projectSummaryStage(activeProject).label} · ${projectSummaryCompletion(activeProject)}%` : "从一句话故事开始,逐步推进到可交付的镜头。"}

+
+
+
+
); } -function QuickCreateCard({ icon, title, description, action, href, onClick }: { icon: ReactNode; title: string; description: string; action: string; href?: string; onClick?: () => void }) { - const content = ( - <> - {icon} - {title} - {description} - {action} - - ); - const className = "app-home-quick-create-card group flex min-h-[148px] flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--card-elevation-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; - return href ? {content} : ; -} - -function RecentProjectRow({ summary, divided }: { summary: ProjectSummary; divided: boolean }) { - const completion = projectSummaryCompletion(summary); +function StatCard({ icon, label, value, hint, to }: { icon: ReactNode; label: string; value: number; hint: string; to: string }) { return ( - - {summary.project.name}{summary.unitCount} 章 · {summary.canvasCount} 张项目画布 · {summary.assetCount} 项资产 - 更新于
{formatRelativeTime(summary.project.updatedAt)}
- 章节{completion}% + + + {icon} + {value} + + + {label} + {hint} + + + ); +} + +function RecentProjectRow({ row }: { row: ProjectSummary }) { + const completion = projectSummaryCompletion(row); + const stage = projectSummaryStage(row); + const archived = row.project.status === "archived"; + return ( + + + + {row.project.name} + {archived ? 已归档 : null} + + + {projectStyleTitle(row.project)} · {sourceTypeLabel(row.project.sourceType)} · {row.canvasCount} 画布 · {row.assetCount} 资产 + + + + {stage.label} + + + + + {row.completedUnitCount}/{row.unitCount} 章 + + {completion}% + + + + + + {formatRelativeTime(row.project.updatedAt)} ); } -function StartMode({ icon, title, description, action, href, onClick }: { icon: ReactNode; title: string; description: string; action: string; href?: string; onClick?: () => void }) { - const content = <>{icon}{title}{description}{action} →; - const className = "grid w-full grid-cols-[20px_minmax(0,1fr)_auto] gap-3 py-4 text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/20"; - return href ? {content} : ; +function QuickItem({ icon, title, description, href, onClick, disabled }: { icon: ReactNode; title: string; description: string; href?: string; onClick?: () => void; disabled?: boolean }) { + const content = ( + <> + {icon} + + {title} + {description} + + + + ); + const className = "home-quick-item group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"; + return href ? ( + + {content} + + ) : ( + + ); } -function FeatureLine({ icon, text }: { icon: ReactNode; text: string }) { - return
{icon}

{text}

; +function projectStyleTitle(project: ProjectSummary["project"]) { + const projectStyle = resolveProjectCanvasStyle(project.stylePresetId, project.styleProfileJson); + return projectStyle?.title || parseStyleProfile(project.styleProfileJson)?.title || resolveCanvasStylePreset(project.stylePresetId)?.title || (project.stylePresetId ? "自定义画风" : "未设置画风"); } +function sourceTypeLabel(value: string) { + return sourceTypeLabels[value] || "其他来源"; +} + +function assetSummaryHint(counts: Partial>) { + const parts = ASSET_KINDS.map(({ key, label }) => ({ label, count: counts[key] || 0 })).filter(({ count }) => count > 0); + if (!parts.length) return "从画布或项目中沉淀素材"; + return parts + .slice(0, 3) + .map(({ label, count }) => `${label} ${count}`) + .join(" · "); +} + +function assetChartData(assets: ReturnType["assets"]) { + const colors = [ + CHART_ACCENT, + "color-mix(in srgb, var(--workspace-accent) 66%, transparent)", + "color-mix(in srgb, var(--workspace-accent) 44%, transparent)", + "color-mix(in srgb, var(--workspace-accent) 28%, transparent)", + "color-mix(in srgb, var(--workspace-accent) 16%, transparent)", + ]; + const counts = new Map(); + for (const asset of assets) counts.set(asset.kind, (counts.get(asset.kind) || 0) + 1); + const entries = ASSET_KINDS.filter(({ key }) => (counts.get(key) || 0) > 0); + return entries.map(({ key, label }, index) => ({ name: label, value: counts.get(key) || 0, color: colors[index % colors.length] })); +} + +function buildActivityBuckets(days: number, taskDates: number[], canvasDates: number[]) { + const buckets: Array<{ label: string; tasks: number; canvases: number }> = []; + const today = new Date(); + today.setHours(0, 0, 0, 0); + for (let offset = days - 1; offset >= 0; offset -= 1) { + const day = new Date(today); + day.setDate(today.getDate() - offset); + const start = day.getTime(); + const end = start + 24 * 60 * 60 * 1000; + buckets.push({ + label: `${day.getMonth() + 1}/${day.getDate()}`, + tasks: taskDates.filter((time) => time >= start && time < end).length, + canvases: canvasDates.filter((time) => time >= start && time < end).length, + }); + } + return buckets; +} + +const tooltipStyle = { + background: "var(--workspace-surface-strong)", + border: "1px solid var(--workspace-border)", + borderRadius: "var(--r-md)", + boxShadow: "var(--elevation-card-hover)", + fontSize: 12, +}; + function formatRelativeTime(value: string) { const diffMinutes = Math.round((new Date(value).getTime() - Date.now()) / 60_000); const formatter = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }); diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 173904491..43bee4d17 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -14252,73 +14252,381 @@ html:not(.dark) .creation-history-drawer-root { .library-drawer .ant-drawer-body { padding: 18px; } } -/* 工作台首页设计系统落地:颜色/字体/间距/形状统一走 token */ -.app-home-workbench { - --home-card-radius: var(--r-md); +/* ===== 工作台首页 · 创作仪表盘(欢迎区 + 统计卡 + 活跃度/素材图表 + 最近项目表格) ===== */ +.app-home-dashboard { --home-panel-radius: var(--r-lg); + --home-panel-surface: var(--workspace-surface-strong); + --home-panel-border: var(--workspace-border); --home-title-fs: var(--fs-title); --home-section-fs: var(--fs-heading-lg); } -.app-home-workbench h1, -.app-home-workbench h2, -.app-home-workbench h3 { +.app-home-dashboard h1, +.app-home-dashboard h2, +.app-home-dashboard h3 { letter-spacing: 0; text-wrap: balance; } -.app-home-workbench h1 { font-size: var(--home-title-fs); } -.app-home-workbench h2 { font-size: var(--home-section-fs); } -.app-home-workbench h3 { font-size: var(--fs-heading); } +.app-home-dashboard h1 { font-size: var(--home-title-fs); } +.app-home-dashboard h2 { font-size: var(--home-section-fs); } +.app-home-dashboard h3 { font-size: var(--fs-heading); } -.app-home-timeline { +/* 面板外壳:卡片、图表、表格共用 */ +.home-panel { border-radius: var(--home-panel-radius); - border-color: var(--workspace-border); - background: var(--workspace-surface-strong); - box-shadow: var(--elevation-card), var(--elevation-hairline); + border: 1px solid var(--home-panel-border); + background: var(--home-panel-surface); + box-shadow: var(--elevation-card); } -.app-home-timeline { - border-color: var(--workspace-border); +.home-panel-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + padding: 16px 18px 4px; } -/* 方案 1:模板工作台式深浅主题卡片 */ -html:not(.dark) .app-home-workbench { - --home-card-bg: #fafafa; - --home-card-border: rgba(17, 17, 17, 0.08); +.app-home-dashboard .home-panel-title { font-size: var(--fs-heading-lg); font-weight: 620; } +.home-panel-sub { margin-top: 3px; font-size: var(--fs-tiny); color: color-mix(in srgb, var(--foreground) 42%, transparent); } +.home-panel-body { padding: 8px 12px 14px; } +.home-panel-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 18px 14px; + border-top: 1px solid var(--home-panel-border); } -.dark .app-home-workbench { - --home-card-bg: #1f1f1f; - --home-card-border: rgba(255, 255, 255, 0.1); +/* 欢迎区 */ +.home-welcome { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 18px 24px; + padding: 6px 0 22px; } -.app-home-quick-create-card { - background: var(--home-card-bg); - border-color: var(--home-card-border); - box-shadow: var(--elevation-card), var(--elevation-hairline); +.home-welcome-kicker { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: var(--fs-label); + font-weight: 600; + letter-spacing: 0.02em; + color: color-mix(in srgb, var(--foreground) 46%, transparent); } -.app-home-quick-create-card:hover { +.app-home-dashboard .home-welcome-title { + margin-top: 10px; + font-size: clamp(24px, 2.4vw, 30px); + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.015em; +} + +.home-welcome-sub { + margin-top: 8px; + max-width: 620px; + font-size: var(--fs-body); + line-height: 1.7; + color: color-mix(in srgb, var(--foreground) 55%, transparent); +} + +.home-welcome-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +/* 统计卡 */ +.home-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.home-stat-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 14px; + border-radius: var(--home-panel-radius); + border: 1px solid var(--home-panel-border); + background: var(--home-panel-surface); + box-shadow: var(--elevation-card); + padding: 16px 18px 15px; + transition: border-color 200ms ease-out, box-shadow 200ms ease-out, transform 200ms ease-out; +} + +.home-stat-card:hover { border-color: color-mix(in srgb, var(--foreground) 18%, transparent); + box-shadow: var(--elevation-card-hover); + transform: translateY(-2px); } -.app-workflow-card { - border-radius: var(--home-card-radius); - border-color: var(--home-card-border); - background: var(--home-card-bg); - box-shadow: var(--elevation-card), var(--elevation-hairline); - transition: transform 240ms var(--motion-ease-out), border-color 240ms ease-out, box-shadow 240ms var(--motion-ease-out); +.home-stat-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -.app-workflow-card:hover { - border-color: color-mix(in srgb, var(--foreground) 18%, transparent); - box-shadow: var(--elevation-card-hover), var(--elevation-hairline); - transform: translateY(-4px); +.home-stat-icon { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); + color: var(--workspace-accent); } -.app-home-template-rail { - border-color: var(--workspace-border); +.home-stat-value { + font-size: 26px; + font-weight: 650; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.home-stat-label { margin-top: 8px; font-size: var(--fs-label); font-weight: 520; color: color-mix(in srgb, var(--foreground) 62%, transparent); } +.home-stat-hint { + margin-top: 3px; + overflow: hidden; + font-size: var(--fs-tiny); + line-height: 1.5; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 图表区 */ +.home-charts-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@media (min-width: 1024px) { + .home-charts-grid { grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.9fr); } +} + +.home-chart-empty { + display: grid; + min-height: 220px; + place-items: center; + padding: 20px; + text-align: center; + font-size: var(--fs-label); + line-height: 1.6; + color: color-mix(in srgb, var(--foreground) 42%, transparent); +} + +.home-donut-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + text-align: center; +} + +.home-donut-center strong { + display: block; + font-size: 22px; + font-weight: 650; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.home-donut-center span { margin-top: 4px; font-size: var(--fs-tiny); color: color-mix(in srgb, var(--foreground) 45%, transparent); } + +.home-donut-legend { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + justify-content: center; + gap: 9px; + padding: 0 6px; +} + +.home-donut-legend li { + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + font-size: var(--fs-label); +} + +.home-donut-legend i { width: 8px; height: 8px; border-radius: 999px; } +.home-donut-legend em { font-style: normal; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-variant-numeric: tabular-nums; } + +/* 最近项目表格 */ +.home-lower-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@media (min-width: 1024px) { + .home-lower-grid { grid-template-columns: minmax(0, 1.5fr) minmax(300px, 0.85fr); } +} + +.home-side-stack { + display: grid; + align-content: start; + gap: 12px; +} + +.home-recent-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 18px 10px; + border-bottom: 1px solid var(--home-panel-border); +} + +.home-recent-search { max-width: 260px; } +.home-recent-scroll { overflow-x: auto; } +.home-recent-table { min-width: 680px; } + +.home-recent-col-head, +.home-recent-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 150px 104px 20px; + gap: 12px; + align-items: center; + padding: 8px 18px; +} + +.home-recent-col-head { + border-bottom: 1px solid var(--home-panel-border); + font-size: var(--fs-tiny); + font-weight: 540; + color: color-mix(in srgb, var(--foreground) 45%, transparent); +} + +.home-recent-row { + padding-block: 11px; + transition: background-color 150ms ease-out; +} + +.home-recent-row + .home-recent-row { border-top: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent); } +.home-recent-row:hover { background: color-mix(in srgb, var(--workspace-accent) 4%, transparent); } + +.home-stage-pill { + display: inline-flex; + max-width: 100%; + align-items: center; + overflow: hidden; + border-radius: 999px; + padding: 3px 8px; + font-size: var(--fs-tiny); + font-weight: 520; + font-style: normal; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + background: color-mix(in srgb, var(--foreground) 7%, transparent); + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-progress-track { + display: block; + height: 6px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} + +.home-progress-fill { + display: block; + height: 100%; + border-radius: 999px; + background: var(--workspace-accent); +} + +.home-recent-empty { + display: grid; + place-items: center; + gap: 10px; + padding: 40px 20px; + text-align: center; +} + +/* 快捷创建与工作流 */ +.home-quick-create { display: flex; flex-direction: column; padding-top: 4px; } + +.home-quick-item { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 16px; + gap: 12px; + align-items: center; + border-radius: var(--r-md); + padding: 10px; + text-align: left; + transition: background-color 150ms ease-out; +} + +.home-quick-item:hover { background: color-mix(in srgb, var(--workspace-accent) 5%, transparent); } + +.home-quick-icon { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); + color: var(--workspace-accent); +} + +.home-quick-title { font-size: var(--fs-body); font-weight: 560; } +.home-quick-desc { margin-top: 2px; font-size: var(--fs-tiny); color: color-mix(in srgb, var(--foreground) 44%, transparent); } + +.home-workflow { display: flex; flex-direction: column; padding-top: 2px; } + +.home-workflow-step { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 10px; + border-radius: var(--r-md); + padding: 8px 10px; + transition: background-color 150ms ease-out; +} + +.home-workflow-step:hover { background: color-mix(in srgb, var(--workspace-accent) 5%, transparent); } + +.home-workflow-num { + display: grid; + width: 24px; + height: 24px; + place-items: center; + border-radius: 7px; + background: color-mix(in srgb, var(--workspace-accent) 8%, transparent); + color: var(--workspace-accent); + font-size: var(--fs-tiny); + font-weight: 650; + font-variant-numeric: tabular-nums; +} + +.home-workflow-title { font-size: var(--fs-body); font-weight: 560; } +.home-workflow-desc { margin-top: 1px; font-size: var(--fs-tiny); color: color-mix(in srgb, var(--foreground) 44%, transparent); } + +.home-workflow-hint { + margin-top: 10px; + padding: 10px 10px 2px; + border-top: 1px dashed var(--home-panel-border); + font-size: var(--fs-tiny); + color: color-mix(in srgb, var(--foreground) 45%, transparent); } /* ===== 创作页 · 分镜工作台(方案 B · 分镜台) ===== */