492 lines
25 KiB
TypeScript
492 lines
25 KiB
TypeScript
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,
|
||
},
|
||
};
|
||
}
|