PMFlow/frontend/src/features/pmflow-os/pages/TeamLayerPage.tsx

492 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 type { CSSProperties, ReactNode } from "react";
import {
ArrowRight,
Bot,
BriefcaseBusiness,
CheckCircle2,
ChevronDown,
CirclePlus,
Code2,
FileText,
FlaskConical,
Maximize2,
Paperclip,
Send,
Settings2,
ShieldAlert,
TrendingUp,
X,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { usePMFlowOsStore } from "@/features/pmflow-os/store";
import type { PMFlowReviewerRouting, PMFlowReviewerRoutingItem, PMFlowRiskLevel } from "@/features/pmflow-os/types";
import { cn } from "@/lib/utils";
const activityItems = [
["新增提取对象", "AI 从对话中提取了 3 个新对象:会议纪要分享、自动任务创建、跨时区支持", "10:24 · 由 PMFlow AI 提取", CirclePlus, "brand"],
["PR 已回写", "PR #128 已合并并回写 2 个需求与 1 个风险", "10:12 · 由 开发智能体", Code2, "success"],
["测试失败", "用例「会议纪要分享 - 权限校验」失败,影响发布门禁", "09:48 · 由 测试智能体", X, "danger"],
["发布风险升级", "风险「第三方转写服务稳定性」升级为高风险", "09:31 · 由 PMFlow AI", ShieldAlert, "warning"],
["决策已记录", "已记录决策:采用混合模型方案(本地 + 云端)", "昨天 18:22 · 由 张子墨", FileText, "brand"],
["用户反馈回写", "新增 12 条用户反馈,主要集中在「长会议跟踪助手」", "昨天 17:05 · 由 运营智能体", TrendingUp, "success"],
] as const;
const decisions = [
["范围调整", "是否将「AI 生成行动项」从 MVP 范围移出", "发起人:张子墨 · 截止:今天 18:00", "高"],
["发布时间确认", "确认 v1.0 版本发布时间窗口(建议 6.15 - 6.18)", "发起人:李文博 · 截止:明天 12:00", "中"],
["风险接受", "接受风险「第三方转写服务稳定性」并制定应对方案", "发起人:PMFlow AI · 截止:后天 17:00", "高"],
["资源冲突", "开发资源与「数据分析看板」项目冲突", "发起人:系统 · 截止:06-14 12:00", "中"],
] as const;
const inboxItems = [
["需求澄清", "会议纪要分享的权限范围需要进一步明确", "来自:PMFlow AI · 10:24", "需多确认", "brand"],
["用户反馈聚类", "长会议识别准确率问题较多,建议评估优化方案", "来自:运营智能体 · 09:40", "需多评估", "warning"],
["范围影响评估", "若移除「AI 生成行动项」,预计节省 2 周开发时间", "来自:开发智能体 · 昨天", "异步决策", "brand"],
] as const;
const agentCards = [
["产品智能体", Bot, "运行中", "梳理会议纪要分享需求细节", 60, "", [["提取 3 个新对象", "10:24"], ["更新需求 PRD-012", "昨天"], ["发起 1 项待决策", "昨天"]]],
["开发智能体", Code2, "运行中", "实现会议纪要分享 API", 75, "", [["PR #128 已合并并回写", "10:12"], ["创建 2 个开发任务", "昨天"], ["更新技术设计文档", "06-10"]]],
["测试智能体", FlaskConical, "阻塞中", "执行回归测试(纪要分享模块)", 40, "用例 2 失败,等待开发修复", [["执行 32 个用例", "09:48"], ["发现 2 个缺陷", "昨天"]]],
["运营智能体", TrendingUp, "运行中", "分析用户反馈与套用数据", 65, "", [["新增 12 条用户反馈", "09:31"], ["生成运营周报", "昨天"], ["更新增长实验建议", "06-10"]]],
] as const;
export default function TeamLayerPage() {
const navigate = useNavigate();
const { state, openReview, projectionWarnings } = usePMFlowOsStore();
const reviewerRouting = state.reviewerRouting || emptyReviewerRouting();
const pendingReviewCount = state.changeItems.filter((item) => item.status === "pending_review").length;
const reviewerWarnings = projectionWarnings.filter((warning) => warning.includes("reviewer_routing_warning"));
const handleOpenReview = (item: PMFlowReviewerRoutingItem) => {
openReview(item.changeSetId);
navigate(item.targetUrl || `/change-review?projectId=${item.projectId}&changeSetId=${item.changeSetId}`);
};
return (
<div className="min-h-full bg-white pb-6 text-slate-950">
<div className="flex flex-col gap-4 min-[1440px]:flex-row min-[1440px]:items-start">
<main className="min-w-0 flex-1 space-y-4">
<ReviewerRoutingPanel
pendingReviewCount={pendingReviewCount}
routings={reviewerRouting}
warnings={reviewerWarnings}
onOpenReview={handleOpenReview}
/>
<div className="grid gap-4 lg:grid-cols-3">
<TeamActivityPanel />
<DecisionPanel />
<RoleInboxPanel />
</div>
<AgentWorkstationPanel />
</main>
<div className="hidden min-[1440px]:block">
<AICopilotPanel />
</div>
</div>
</div>
);
}
function ReviewerRoutingPanel({
pendingReviewCount,
routings,
warnings,
onOpenReview,
}: {
pendingReviewCount: number;
routings: PMFlowReviewerRouting;
warnings: string[];
onOpenReview: (item: PMFlowReviewerRoutingItem) => void;
}) {
const visibleRoutings = routings.items.slice(0, 3);
return (
<Panel>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#2463EB]">
<ShieldAlert className="h-4 w-4" />
审查路由
</div>
<h2 className="mt-2 text-xl font-semibold text-slate-800">待审 ChangeSet 审查路由</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
基于项目成员角色、待审变更风险和跨对象影响生成建议审查人;这里只展示建议,不自动分派审查人。
</p>
</div>
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
<MetricPill label="待审变更" value={String(pendingReviewCount)} />
<MetricPill label="高风险" value={String(routings.summary.highRiskCount)} />
<MetricPill label="缺审查人" value={String(routings.summary.missingReviewerCount)} />
</div>
</div>
{routings.status === "warning" ? (
<div className="mt-4 rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm leading-6 text-orange-700">
{reviewerRoutingWarningText(routings.warningCode, warnings)}
</div>
) : null}
{visibleRoutings.length > 0 ? (
<div className="mt-5 grid gap-4 xl:grid-cols-3">
{visibleRoutings.map((routing) => (
<article key={routing.changeSetId} className="rounded-xl border border-[#e2e9f5] bg-[#fbfdff] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("rounded-lg px-2 py-1 text-xs font-semibold", riskTone(routing.riskLevel))}>
{riskLabel(routing.riskLevel)}
</span>
<span className="rounded-lg bg-[#eef4ff] px-2 py-1 text-xs font-semibold text-[#2463EB]">
{sourceLabel(routing.sourceType)}
</span>
<span className="rounded-lg bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
{priorityLabel(routing.priority)}
</span>
</div>
<h3 className="mt-3 text-base font-semibold text-slate-800">{routing.changeSetTitle}</h3>
<p className="mt-2 text-sm leading-6 text-slate-500">{routing.reviewReason}</p>
</div>
<button
type="button"
onClick={() => onOpenReview(routing)}
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-[#cfe0ff] bg-white px-3 py-2 text-sm font-semibold text-[#2463EB] transition hover:border-[#2463EB] hover:bg-[#eef4ff] focus:outline-none focus:ring-2 focus:ring-[#2463EB]/20"
>
去审查 <ArrowRight className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{routing.changeTypes.map((type) => (
<span key={type} className="rounded-lg border border-[#e2e9f5] bg-white px-2 py-1 text-xs font-semibold text-slate-500">
{type}
</span>
))}
<span className="rounded-lg border border-[#e2e9f5] bg-white px-2 py-1 text-xs font-semibold text-slate-500">
待审 {routing.pendingItemCount}
</span>
<span className="rounded-lg border border-[#e2e9f5] bg-white px-2 py-1 text-xs font-semibold text-slate-500">
证据 {routing.evidenceCount}
</span>
</div>
<div className="mt-4 space-y-2">
<div className="text-xs font-semibold text-slate-400">建议审查人</div>
{routing.suggestedReviewer ? (
<div className="rounded-lg border border-[#e2e9f5] bg-white px-3 py-3">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-slate-800">{routing.suggestedReviewer.displayName}</span>
<span className="rounded-md bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">{routing.suggestedReviewer.role}</span>
</div>
<p className="mt-2 text-xs leading-5 text-slate-500">{routing.suggestedReviewer.reason}</p>
</div>
) : (
<div className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-3 text-sm leading-6 text-orange-700">
当前项目缺少 REVIEWER/OWNER,建议先补充项目审查人。兜底角色:{routing.fallbackReviewerRole || "OWNER"}
</div>
)}
</div>
</article>
))}
</div>
) : (
<div className="mt-5 rounded-xl border border-[#e2e9f5] bg-[#fbfdff] px-4 py-5 text-sm leading-6 text-slate-500">
{routings.status === "empty" ? "当前没有待团队审查的变更。" : "团队审查路由暂时不可用,请稍后重试。"}
</div>
)}
</Panel>
);
}
function MetricPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-[#e2e9f5] bg-[#fbfdff] px-3 py-2">
<div className="text-xs font-semibold text-slate-400">{label}</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{value}</div>
</div>
);
}
function TeamActivityPanel() {
return (
<Panel className="overflow-hidden" style={{ height: 640 }}>
<PanelHeader
title="团队动态"
action={
<div className="flex items-center gap-2">
<button type="button" className="inline-flex h-8 items-center gap-1 rounded-lg border border-[#e6edf7] bg-white px-3 text-xs font-semibold text-slate-500">
全部事件 <ChevronDown className="h-3.5 w-3.5" />
</button>
<button type="button" className="flex h-8 w-8 items-center justify-center rounded-lg border border-[#e6edf7] text-slate-500">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="mt-5 rounded-xl border border-[#eef2f8] px-4 py-4">
<div className="relative">
<div className="absolute left-[14px] top-4 h-[calc(100%-32px)] w-px bg-[#e3eaf5]" />
{activityItems.map(([title, detail, time, Icon, tone], index) => (
<article key={title} className="relative grid grid-cols-[42px_minmax(0,1fr)_28px] gap-3 border-b border-[#eef2f8] py-4 last:border-b-0">
<span className={cn("relative z-10 flex h-8 w-8 items-center justify-center rounded-full text-white", iconTone(tone))}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-slate-800">{title}</h3>
<p className="mt-1 text-xs font-medium leading-5 text-slate-500">{detail}</p>
<p className="mt-2 text-xs text-slate-400">{time}</p>
</div>
<img src={`https://api.dicebear.com/7.x/avataaars/svg?seed=team-${index}`} alt="" className="mt-8 h-7 w-7 rounded-full bg-[#eef4ff]" />
</article>
))}
</div>
<LinkButton className="mx-auto mt-4">查看全部动态</LinkButton>
</div>
</Panel>
);
}
function DecisionPanel() {
return (
<Panel className="overflow-hidden" style={{ height: 640 }}>
<PanelHeader
title="决策台"
action={
<div className="flex items-center gap-4 text-sm font-semibold">
<span className="rounded-lg bg-[#eef4ff] px-3 py-1.5 text-[#2463EB]">待拍板 6</span>
<button type="button" className="text-slate-400">我发起的</button>
</div>
}
/>
<div className="mt-5 space-y-4">
{decisions.map(([title, detail, meta, risk]) => (
<article key={title} className="rounded-xl border border-[#e2e9f5] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 gap-3">
<span className="mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-[#2463EB] text-[#2463EB]">
<CheckCircle2 className="h-3.5 w-3.5" />
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-800">{title}</h3>
<span className={cn("h-2.5 w-2.5 rounded-full", risk === "高" ? "bg-red-500" : "bg-orange-500")} />
<span className="text-xs font-semibold text-slate-500">{risk}</span>
</div>
<p className="mt-2 text-sm leading-5 text-slate-500">{detail}</p>
<p className="mt-4 text-xs text-slate-400">{meta}</p>
</div>
</div>
<button type="button" className="mt-10 shrink-0 rounded-lg border border-[#cfe0ff] bg-white px-3 py-2 text-sm font-semibold text-[#2463EB]">
去处理
</button>
</div>
</article>
))}
</div>
<LinkButton className="mx-auto mt-16">查看全部待拍板</LinkButton>
</Panel>
);
}
function RoleInboxPanel() {
return (
<Panel className="overflow-hidden" style={{ height: 640 }}>
<PanelHeader title="角色收件箱" />
<div className="mt-5 flex items-center gap-5 text-sm font-semibold text-slate-400">
<span className="rounded-lg bg-[#eef4ff] px-4 py-2 text-[#2463EB]">产品 3</span>
<span>开发 4</span>
<span>测试 2</span>
<span>运营 2</span>
</div>
<div className="mt-5 space-y-4">
{inboxItems.map(([title, detail, meta, tag, tone]) => (
<article key={title} className="rounded-xl border border-[#e2e9f5] bg-white p-4">
<h3 className="text-base font-semibold text-slate-800">{title}</h3>
<p className="mt-2 text-sm leading-5 text-slate-500">{detail}</p>
<div className="mt-5 flex items-center justify-between text-xs text-slate-400">
<span>{meta}</span>
<span className={cn("rounded-md px-2 py-1 font-semibold", tone === "warning" ? "bg-orange-50 text-orange-500" : "bg-[#eef4ff] text-[#2463EB]")}>{tag}</span>
</div>
</article>
))}
</div>
<LinkButton className="mx-auto mt-[86px]">查看全部消息</LinkButton>
</Panel>
);
}
function AgentWorkstationPanel() {
return (
<Panel>
<div className="mb-5 flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-800">智能体工位</h2>
<button type="button" className="inline-flex h-9 items-center gap-1 rounded-lg border border-[#e6edf7] bg-white px-3 text-sm font-semibold text-slate-500">
全部智能体状态 <ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="grid gap-5 lg:grid-cols-4">
{agentCards.map(([title, Icon, status, task, progress, blocked, outputs]) => (
<article key={title} className="rounded-xl border border-[#e2e9f5] bg-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#eef4ff] text-[#2463EB]">
<Icon className="h-5 w-5" />
</span>
<h3 className="font-semibold text-slate-800">{title}</h3>
</div>
<span className={cn("rounded-full px-3 py-1 text-xs font-semibold", status === "阻塞中" ? "bg-red-50 text-red-500" : "bg-emerald-50 text-emerald-600")}>{status}</span>
</div>
<div className="mt-5 text-xs font-semibold text-slate-400">当前任务</div>
<p className="mt-2 text-sm font-medium text-slate-700">{task}</p>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span>进度</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 rounded-full bg-[#e8eef8]">
<div className="h-full rounded-full bg-[#2463EB]" style={{ width: `${progress}%` }} />
</div>
</div>
{blocked ? <p className="mt-4 text-xs font-semibold text-red-500">阻塞原因:{blocked}</p> : null}
<div className="mt-4 text-xs font-semibold text-[#2463EB]">近期产出</div>
<div className="mt-3 space-y-2">
{outputs.map(([label, time]) => (
<div key={label} className="flex items-center justify-between gap-3 text-xs text-slate-500">
<span className="flex min-w-0 items-center gap-2">
<FileText className="h-3.5 w-3.5 shrink-0 text-[#2463EB]" />
<span className="truncate">{label}</span>
</span>
<span className="shrink-0">{time}</span>
</div>
))}
</div>
</article>
))}
</div>
</Panel>
);
}
function AICopilotPanel() {
return (
<aside className="flex min-h-[920px] flex-col rounded-xl border border-[#e2e9f5] bg-white p-5 shadow-[0_1px_2px_rgba(15,23,42,0.03)] xl:w-[380px] xl:shrink-0">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-800">AI 副驾</h2>
<div className="flex items-center gap-2 text-slate-400">
<Maximize2 className="h-4 w-4" />
<X className="h-4 w-4" />
</div>
</div>
<div className="mt-6 grid grid-cols-3 border-b border-[#edf2f8] text-center text-sm font-semibold">
{["建议", "解释", "下一步"].map((tab, index) => (
<button key={tab} type="button" className={cn("pb-4", index === 0 ? "rounded-t-lg bg-[#eef4ff] py-3 text-[#2463EB]" : "text-slate-500")}>{tab}</button>
))}
</div>
<div className="mt-6 rounded-xl bg-[#f7fbff] p-4 text-sm leading-6 text-slate-600">基于当前项目状态,我有以下协同建议:</div>
<CopilotBlock title="优先关注">
{[
["测试失败影响发布门禁", "建议优先修复「权限校验」用例"],
["范围调整待决策", "共识未达成,可能影响开发排期"],
["高风险项需应对", "第三方转写服务稳定性风险升级"],
].map(([title, detail], index) => (
<div key={title} className="rounded-xl border border-[#e2e9f5] bg-white p-4">
<div className="flex items-start gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#2463EB] text-xs font-semibold text-white">{index + 1}</span>
<div>
<h4 className="text-sm font-semibold text-slate-800">{title}</h4>
<p className="mt-2 text-xs leading-5 text-slate-500">{detail}</p>
</div>
</div>
</div>
))}
</CopilotBlock>
<CopilotBlock title="推荐协同行动">
{[
["催办", "测试用例修复"],
["发起", "范围评审会议"],
["完善", "风险应对方案"],
].map(([verb, label]) => (
<button key={label} type="button" className="flex w-full items-center justify-between rounded-lg border border-[#cfe0ff] bg-white px-4 py-3 text-sm text-slate-700">
<span><span className="mr-3 font-semibold text-[#2463EB]">{verb}</span>{label}</span>
<ArrowRight className="h-4 w-4 text-[#2463EB]" />
</button>
))}
</CopilotBlock>
<div className="mt-auto pt-6">
<div className="flex h-14 items-center gap-3 rounded-xl border border-[#e2e9f5] bg-white px-3">
<input className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400" placeholder="问我项目或协同相关的问题..." />
<Paperclip className="h-4 w-4 text-slate-400" />
<button type="button" className="flex h-9 w-9 items-center justify-center rounded-full bg-[#2463EB] text-white"><Send className="h-4 w-4 fill-white" /></button>
</div>
<div className="mt-5 text-sm font-semibold text-slate-400">关联上下文</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{["对象 32", "决策 7", "风险 5"].map((item) => <div key={item} className="rounded-lg border border-[#e2e9f5] bg-white px-3 py-2 text-center text-sm text-slate-600">{item}</div>)}
</div>
</div>
</aside>
);
}
function Panel({ children, className, style }: { children: ReactNode; className?: string; style?: CSSProperties }) {
return <section className={cn("rounded-xl border border-[#e2e9f5] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.03)]", className)} style={style}>{children}</section>;
}
function PanelHeader({ title, action }: { title: string; action?: ReactNode }) {
return <div className="flex items-center justify-between gap-3"><h2 className="text-xl font-semibold text-slate-800">{title}</h2>{action}</div>;
}
function LinkButton({ children, className }: { children: ReactNode; className?: string }) {
return <button type="button" className={cn("flex items-center gap-1 text-sm font-semibold text-[#2463EB]", className)}>{children}<ArrowRight className="h-4 w-4" /></button>;
}
function CopilotBlock({ title, children }: { title: string; children: ReactNode }) {
return <section className="mt-6 rounded-xl border border-[#e2e9f5] bg-white p-4"><h3 className="mb-4 text-lg font-semibold text-[#2463EB]">{title}</h3><div className="space-y-3">{children}</div></section>;
}
function iconTone(tone: string) {
if (tone === "success") return "bg-emerald-600";
if (tone === "danger") return "bg-red-500";
if (tone === "warning") return "bg-orange-500";
return "bg-[#2463EB]";
}
function riskLabel(level: PMFlowRiskLevel) {
if (level === "high") return "高风险";
if (level === "medium") return "中风险";
return "低风险";
}
function riskTone(level: PMFlowRiskLevel) {
if (level === "high") return "bg-red-50 text-red-600";
if (level === "medium") return "bg-orange-50 text-orange-600";
return "bg-emerald-50 text-emerald-600";
}
function sourceLabel(sourceType: PMFlowReviewerRoutingItem["sourceType"]) {
if (sourceType === "iteration_recommendation") return "下一轮迭代";
if (sourceType === "ai_extraction") return "AI 提取";
if (sourceType === "manual") return "手动";
return "来源未知";
}
function priorityLabel(priority: PMFlowReviewerRoutingItem["priority"]) {
if (priority === "urgent") return "紧急";
if (priority === "elevated") return "需关注";
return "常规";
}
function reviewerRoutingWarningText(code: string | null | undefined, warnings: string[]) {
if (code === "reviewer_missing") return "当前项目缺少 REVIEWER/OWNER,建议先补充项目审查人。";
if (code === "evidence_missing") return "待审变更缺少足够证据,建议回到 Change Review 查看来源。";
if (warnings.length > 0) return "团队审查路由存在 warning,请优先检查 reviewer 和证据。";
return "团队审查路由暂时不可用,请稍后重试。";
}
function emptyReviewerRouting(): PMFlowReviewerRouting {
return {
status: "empty",
warningCode: null,
items: [],
summary: {
totalPendingReview: 0,
highRiskCount: 0,
missingReviewerCount: 0,
iterationDraftCount: 0,
},
};
}