PMFlow/frontend/src/components/sstc/SSTCGlobalHeader.tsx

239 lines
9.7 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 { Download, Settings, Sparkles, Users } from "lucide-react";
import { Pie, PieChart, ResponsiveContainer, Cell } from "recharts";
import type { SSTCOverview } from "@/api/sstc";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { compactTitle } from "@/lib/title";
import { formatProjectNameWithId, toZhRole, toZhStatus } from "@/lib/display";
import { formatBeijingDateTime } from "@/lib/datetime";
interface SSTCGlobalHeaderProps {
overview: SSTCOverview;
onOpenAi: () => void;
onOpenSettings: () => void;
onExport: () => void;
onOpenWorkloads: () => void;
}
function statusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
const v = (status || "").toLowerCase();
if (v.includes("active") || v.includes("进行")) return "default";
if (v.includes("paused")) return "secondary";
if (v.includes("completed") || v.includes("done")) return "outline";
return "secondary";
}
function scoreColor(score: number): string {
if (score < 60) return "#dc2626";
if (score < 80) return "#ca8a04";
return "#16a34a";
}
function ratioColor(ratio: number): string {
if (ratio > 1.0) return "#dc2626";
if (ratio >= 0.7) return "#ca8a04";
return "#16a34a";
}
function ratioClass(ratio: number): string {
if (ratio > 1.0) return "text-rose-600";
if (ratio < 0.3) return "text-sky-600";
return "text-emerald-600";
}
function memberStatusLabel(status: "idle" | "normal" | "overloaded"): string {
if (status === "overloaded") return "过载";
if (status === "idle") return "空闲";
return "正常";
}
function memberStatusClass(status: "idle" | "normal" | "overloaded"): string {
if (status === "overloaded") return "bg-rose-100 text-rose-700";
if (status === "idle") return "bg-sky-100 text-sky-700";
return "bg-emerald-100 text-emerald-700";
}
function normalizeRoleLabel(rawRole: string): string {
const primaryRole = String(rawRole || "")
.split(",")
.map((item) => item.trim())
.find(Boolean);
return toZhRole(primaryRole || rawRole);
}
export function SSTCGlobalHeader({
overview,
onOpenAi,
onOpenSettings,
onExport,
onOpenWorkloads,
}: SSTCGlobalHeaderProps) {
const alignmentColor = scoreColor(overview.alignment_score || 0);
const progress = overview.task_progress.progress || 0;
const ratio = overview.workload_health.avg_ratio || 0;
const workloadMembers = overview.workload_health.members || [];
const projectLabel = formatProjectNameWithId({
id: overview.project.id,
title: overview.project.title,
});
const compactProjectTitle = compactTitle(projectLabel, 24);
return (
<div className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-3">
<CardHeader className="min-w-0 pb-3">
<CardTitle className="text-xl" title={projectLabel}>
{compactProjectTitle}
</CardTitle>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(overview.project.status)}>
{toZhStatus(overview.project.status)}
</Badge>
<span className="text-xs text-muted-foreground">
链路编号: {overview.chain_id || "-"}
</span>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>项目描述:{overview.project.description || "暂无"}</p>
<p>创建时间(北京时间):{formatBeijingDateTime(overview.project.created_at)}</p>
<p>任务总数:{overview.project.task_count}</p>
</CardContent>
</Card>
<div className="grid gap-3 sm:grid-cols-2 xl:col-span-7 xl:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">全局认知对齐度</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: "score", value: Math.max(0, Math.min(100, overview.alignment_score || 0)) },
{ name: "rest", value: Math.max(0, 100 - (overview.alignment_score || 0)) },
]}
innerRadius={22}
outerRadius={34}
dataKey="value"
startAngle={90}
endAngle={-270}
stroke="none"
>
<Cell fill={alignmentColor} />
<Cell fill="hsl(var(--muted))" />
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<p className="text-lg font-semibold sm:text-xl" style={{ color: alignmentColor }}>
{Math.round(overview.alignment_score || 0)}%
</p>
<p className="text-xs text-muted-foreground">
未确认节点 {overview.pending_alignment_nodes} | 冲突 {overview.unresolved_conflicts}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">项目整体进度</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-xl font-semibold sm:text-2xl">{Math.round(progress)}%</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary" style={{ width: `${Math.min(progress, 100)}%` }} />
</div>
<p className="text-xs text-muted-foreground">
已完成 {overview.task_progress.done}/{overview.task_progress.total}
</p>
<p className="text-xs text-muted-foreground">
延期 {overview.task_progress.overdue} | 阻塞 {overview.task_progress.blocked} | 今日完成 {overview.task_progress.today_done}
</p>
</CardContent>
</Card>
<Card className="cursor-pointer transition-colors hover:bg-accent/40" onClick={onOpenWorkloads}>
<CardHeader className="pb-2">
<CardTitle className="text-base">团队负载健康度</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xl font-semibold sm:text-2xl" style={{ color: ratioColor(ratio) }}>
{(ratio * 100).toFixed(0)}%
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full"
style={{
width: `${Math.min(ratio * 100, 100)}%`,
backgroundColor: ratioColor(ratio),
}}
/>
</div>
<p className="text-xs text-muted-foreground">
过载 {overview.workload_health.overloaded_count} 人 | 空闲 {overview.workload_health.idle_count} 人
</p>
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Users className="h-3 w-3" />
覆盖成员 {overview.workload_health.member_count} 人
</p>
<div className="space-y-1.5 rounded-lg border border-slate-200/70 bg-slate-50/70 p-2">
{workloadMembers.length === 0 ? (
<p className="text-xs text-slate-500">暂无成员负载明细,请先分配项目成员</p>
) : (
workloadMembers.slice(0, 4).map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between gap-2 rounded-md bg-white px-2 py-1 shadow-[0_1px_2px_rgba(15,23,42,0.06)]"
>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-800">{member.name}</p>
<p className="truncate text-[11px] text-slate-500">{normalizeRoleLabel(member.role)}</p>
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs font-semibold ${ratioClass(Number(member.workload_ratio || 0))}`}>
{(Number(member.workload_ratio || 0) * 100).toFixed(0)}%
</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${memberStatusClass(member.status)}`}
>
{memberStatusLabel(member.status)}
</span>
</div>
</div>
))
)}
{workloadMembers.length > 4 ? (
<p className="text-[11px] text-slate-500">其余 {workloadMembers.length - 4} 人请点击查看完整负载</p>
) : null}
</div>
</CardContent>
</Card>
</div>
<Card className="xl:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base">核心操作</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button className="w-full" onClick={onOpenAi}>
<Sparkles className="mr-2 h-4 w-4" />
AI 项目管家
</Button>
<Button variant="outline" className="w-full" onClick={onOpenSettings}>
<Settings className="mr-2 h-4 w-4" />
项目设置
</Button>
<Button variant="ghost" className="w-full" onClick={onExport}>
<Download className="mr-2 h-4 w-4" />
导出链路文档
</Button>
</CardContent>
</Card>
</div>
);
}