feat: 仪表盘 - 多项目蓝图聚合视图

This commit is contained in:
lanrtop 2026-04-05 02:45:19 +09:00
parent 00a6d4e335
commit fbbfd4f205

View File

@ -1,9 +1,203 @@
export function OverviewPage() { import { useState } from "react";
import { useQuery, useQueries } from "@tanstack/react-query";
import { listBuffedProjects, getBlueprint, getProjects, type BuffStatus, type BlueprintData, type Project } from "../../lib/commands";
import { BlueprintModal } from "../dashboard/BlueprintModal";
// ── 时间格式化 ────────────────────────────────────────────────────────────────
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "刚刚";
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}
// ── 单个项目卡片 ──────────────────────────────────────────────────────────────
function BuffProjectCard({
buff,
blueprint,
projectName,
onOpenBlueprint,
}: {
buff: BuffStatus;
blueprint?: BlueprintData | null;
projectName: string;
onOpenBlueprint: () => void;
}) {
const tasks = blueprint?.manifest.modules.flatMap((m) => m.tasks) ?? [];
const total = tasks.length;
const done = tasks.filter((t) => t.status === "done").length;
const inProgress = tasks.filter((t) => t.status === "in_progress").length;
const todo = tasks.filter((t) => t.status === "todo").length;
const blocked = tasks.filter((t) => t.status === "blocked").length;
const progress = total > 0 ? Math.round((done / total) * 100) : 0;
return ( return (
<div className="flex flex-col items-center justify-center h-full text-center text-gray-300 select-none"> <div className="bg-white rounded-xl border border-gray-100 p-4 space-y-3 hover:border-purple-200 transition-colors">
<div className="text-5xl mb-4">📊</div> <div className="flex items-center justify-between gap-2">
<p className="text-base font-medium text-gray-400"></p> <div className="flex items-center gap-2 min-w-0">
<p className="text-xs text-gray-500 mt-1"></p> <span className="text-sm font-semibold text-gray-800 truncate">{projectName}</span>
<span className="text-[10px] text-purple-600 bg-purple-50 border border-purple-200 px-1.5 py-0.5 rounded-full shrink-0"> Buff</span>
</div>
<button
onClick={onOpenBlueprint}
className="text-[11px] text-blue-600 bg-blue-50 hover:bg-blue-100 px-2 py-1 rounded-lg shrink-0 transition-colors"
>
</button>
</div>
{total > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-[10px] text-gray-400">
<span>{done}/{total} </span>
<span>{progress}%</span>
</div>
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full bg-green-400 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{total > 0 && (
<div className="flex gap-3 flex-wrap">
{inProgress > 0 && <span className="text-[11px] text-blue-600">🔵 {inProgress} </span>}
{todo > 0 && <span className="text-[11px] text-amber-500">📋 {todo} </span>}
{blocked > 0 && <span className="text-[11px] text-red-500">🔴 {blocked} </span>}
{inProgress === 0 && todo === 0 && blocked === 0 && (
<span className="text-[11px] text-green-600"> </span>
)}
</div>
)}
<div className="flex items-center justify-between text-[10px] text-gray-300 pt-1 border-t border-gray-50">
<span className="truncate max-w-[60%]">{buff.project_path}</span>
{buff.last_sync_at ? (
<span> {relativeTime(buff.last_sync_at)}</span>
) : (
<span></span>
)}
</div>
</div>
);
}
// ── 主页面 ───────────────────────────────────────────────────────────────────
export function OverviewPage() {
const [openModal, setOpenModal] = useState<{ projectName: string; projectPath: string; projectId: string } | null>(null);
const { data: buffs = [], isLoading: buffsLoading } = useQuery<BuffStatus[]>({
queryKey: ["buffed-projects"],
queryFn: listBuffedProjects,
staleTime: 10000,
});
const { data: projects = [] } = useQuery<Project[]>({
queryKey: ["projects"],
queryFn: () => getProjects(),
staleTime: 60000,
});
const blueprintResults = useQueries({
queries: buffs.map((b) => ({
queryKey: ["blueprint", b.project_path],
queryFn: () => getBlueprint(b.project_path),
staleTime: 30000,
})),
});
const projectMap = new Map((projects as Project[]).map((p: Project) => [p.id, p]));
const getName = (buff: BuffStatus) =>
(projectMap.get(buff.project_id) as Project | undefined)?.name
?? buff.project_path.split(/[/\\]/).filter(Boolean).pop()
?? buff.project_path;
// 跨项目进行中任务
const inProgressTasks = buffs.flatMap((b, i) => {
const bp = blueprintResults[i]?.data as BlueprintData | null | undefined;
if (!bp) return [];
return bp.manifest.modules
.flatMap((m) => m.tasks)
.filter((t) => t.status === "in_progress")
.map((t) => ({ title: t.title, projectName: getName(b) }));
});
if (buffsLoading) {
return (
<div className="flex items-center justify-center h-full text-gray-300 text-sm"></div>
);
}
if (buffs.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center text-gray-300 select-none">
<div className="text-5xl mb-4"></div>
<p className="text-base font-medium text-gray-400"> Buff</p>
<p className="text-xs text-gray-400 mt-1"> Buff</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-6 space-y-6">
<div>
<h1 className="text-base font-semibold text-gray-800"></h1>
<p className="text-xs text-gray-400 mt-0.5">{buffs.length} Buff</p>
</div>
{/* 跨项目进行中任务 */}
{inProgressTasks.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">🔵 </p>
<div className="space-y-1.5">
{inProgressTasks.map((item, i) => (
<div key={i} className="flex items-center justify-between bg-white rounded-lg border border-blue-100 px-3 py-2">
<span className="text-xs text-gray-700 truncate flex-1">{item.title}</span>
<span className="text-[10px] text-blue-400 shrink-0 ml-2">{item.projectName}</span>
</div>
))}
</div>
</div>
)}
{/* Buff 项目卡片 */}
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider"> Buff </p>
<div className="space-y-3">
{buffs.map((buff, i) => (
<BuffProjectCard
key={buff.project_path}
buff={buff}
blueprint={blueprintResults[i]?.data as BlueprintData | null | undefined}
projectName={getName(buff)}
onOpenBlueprint={() =>
setOpenModal({
projectName: getName(buff),
projectPath: buff.project_path,
projectId: buff.project_id,
})
}
/>
))}
</div>
</div>
</div>
{openModal && (
<BlueprintModal
projectName={openModal.projectName}
projectPath={openModal.projectPath}
projectId={openModal.projectId}
onClose={() => setOpenModal(null)}
/>
)}
</div> </div>
); );
} }