Files
ftb-project-management/apps/web/app/versions/[id]/page.tsx
Script Generator 52a88626d4 feat(版本): 完善计划覆盖与工作台待办
关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 13:43:18 +08:00

1351 lines
76 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.

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive';
import type { Role } from '@/lib/stage';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { FilterSelect } from '@/components/FilterSelect';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
import { CHANGE_REASON_LABEL } from '@/lib/requirement';
import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
import { PlanTab } from '@/components/version/PlanTab';
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
import { TestCaseTab } from '@/components/test-case/TestCaseTab';
import { BugTab } from '@/components/bug/BugTab';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
import { getRequirementCoverageSummary } from '@/lib/version-plan';
function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-';
return value.includes('T') ? formatDateTime(value) : value;
}
const BUG_SEVERITY_SEGMENTS = [
{ key: 'critical', label: '致命', color: 'bg-red-500' },
{ key: 'major', label: '严重', color: 'bg-orange-500' },
{ key: 'minor', label: '一般', color: 'bg-amber-400' },
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
] as const;
const RECOMMENDATION_ROLE_LABEL: Record<RecommendableRole, string> = {
frontend: '前端',
backend: '后端',
testing: '测试',
};
const RECOMMENDATION_CONFIDENCE_LABEL = {
high: '高置信',
medium: '中置信',
low: '低置信',
} as const;
const TABS = [
{ key: 'overview', label: '概览', permission: null as string | null },
{ key: 'requirements', label: '关联需求', permission: 'version.req:view' },
{ key: 'research', label: '调研', permission: 'version.research:view' },
{ key: 'product', label: '产品方案', permission: 'version.product_plan:view' },
{ key: 'ui', label: 'UI设计', permission: 'version.ui_plan:view' },
{ key: 'tasks', label: '开发任务', permission: 'version.devtask:view' },
{ key: 'testcases', label: '测试用例', permission: 'version.testcase:view' },
{ key: 'bugs', label: 'BUG', permission: 'version.bug:view' },
];
export default function VersionDetailPage() {
const params = useParams();
const router = useRouter();
const versionId = params.id as string;
const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
const { requirements, fetchRequirements, updateRequirement, createRequirement } = useRequirementStore();
const { records, fetchRecords } = useOvertimeStore();
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore();
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs, deleteBug } = useBugStore();
const user = useAuthStore((s) => s.user);
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
const memberCandidates = useMemo(
() => allMembers.map((member) => ({
id: member.id,
name: member.name,
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
})),
[allMembers, departments],
);
const visibleTabs = useMemo(
() => TABS.filter((t) => t.permission === null || hasPermission(currentRole, t.permission)),
[currentRole],
);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => {
if (visibleTabs.length > 0 && !visibleTabs.find((t) => t.key === activeTab)) {
setActiveTab(visibleTabs[0].key);
}
}, [visibleTabs, activeTab]);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRecommendModal, setShowRecommendModal] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => {
let active = true;
setRecommendationDataReady(false);
Promise.all([Promise.resolve(fetchMembers()), Promise.resolve(fetchCategories())]).finally(() => {
if (active) setRecommendationDataReady(true);
});
return () => { active = false; };
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
useEffect(() => {
if (!version || !plans.length) return;
if (version.status !== 'planned' && version.status !== 'developing') return;
const today = new Date().toISOString().slice(0, 10);
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today);
if (versionPlans.length === 0) return;
let targetStage = '';
for (const p of versionPlans) {
const s = stageMap[p.type];
if (!targetStage || stageOrder.indexOf(s) > stageOrder.indexOf(targetStage)) {
targetStage = s;
}
}
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
}
}, [plans, version, versionId, updateVersion]);
if (!version) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-[var(--ink-muted)]"></p>
<button onClick={() => router.push('/versions')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
// 权限校验:只有参与人员可以访问(超管不受限)
const currentUserName = user?.name || '';
const isSuperAdmin = !!currentRole && currentRole.permissions.includes('*');
const isMember = isSuperAdmin || (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName);
if (!isMember) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-[var(--ink-muted)]">访</p>
<button onClick={() => router.push('/versions')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id);
const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id));
const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId));
const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id);
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
product.versions.forEach((item) => {
const uniqueNames = new Set((item.members ?? []).map((member) => member.name));
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
});
});
const memberRecommendationGroups = recommendVersionMembers({
candidates: memberCandidates,
currentMembers: version.members ?? [],
devTasks,
testCases,
bugs,
overtimeRecords: records,
versionDeadline: version.expectedReleaseDate,
scopeRequirements: recommendationVersionReqs,
scopeDevTasks: recommendationVersionDevTasks,
scopeTestCases: recommendationVersionTestCases,
taskCategories,
historicalStats: memberCandidates.flatMap((member) => {
const projectParticipationCount = currentSystemParticipation.get(member.name);
return typeof projectParticipationCount === 'number'
? [{ name: member.name, projectParticipationCount }]
: [];
}),
});
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
if (version.status === 'planned') {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
// 释放关联需求
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
// 清理计划任务
plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id));
// 清理开发任务
const versionReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
devTasks.filter((t) => versionReqIds.has(t.requirementId)).forEach((t) => deleteDevTask(t.id));
// 清理测试用例和Bug
testCases.filter((c) => c.versionId === version.id).forEach((c) => deleteTestCase(c.id));
bugs.filter((b) => b.versionId === version.id).forEach((b) => deleteBug(b.id));
deleteVersion(version.productId, version.id);
router.push('/versions');
}
}, danger: true });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} else if (version.status === 'developing') {
buttons.push({ label: '暂停', action: () => updateVersion(version.productId, version.id, { status: 'paused' }) });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
} else if (version.status === 'paused') {
buttons.push({ label: '恢复', action: () => updateVersion(version.productId, version.id, { status: 'developing' }) });
buttons.push({ label: '关闭', action: () => updateVersion(version.productId, version.id, { status: 'closed' }), danger: true });
}
return buttons.map((btn) => (
<button
key={btn.label}
onClick={btn.action}
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{btn.label}
</button>
));
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center">
<button onClick={() => router.push('/versions')} className="flex items-center gap-1 rounded-md px-1.5 py-1 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<span className="ml-2 text-[var(--ink-muted)]">/</span>
<button onClick={() => router.push('/products')} className="ml-2 text-[12px] text-[var(--ink-muted)] hover:text-[var(--accent)] hover:underline">{version.productName}</button>
{version.projectName && version.projectName !== '未关联' && (
<>
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
<span className="ml-1.5 text-[12px] text-[var(--ink-muted)]">{version.projectName}</span>
</>
)}
<span className="ml-1.5 text-[var(--ink-muted)]">/</span>
<span className="ml-1.5 text-[15px] font-semibold text-[var(--ink)]">{version.name}</span>
</div>
<div className="flex items-center gap-2">{renderActions()}</div>
</header>
{/* Tab bar */}
<div className="flex items-center gap-0 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
{visibleTabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-[13px] font-medium border-b-2 transition-colors ${activeTab === tab.key ? 'border-[var(--accent)] text-[var(--ink)]' : 'border-transparent text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{activeTab === 'overview' ? (
(() => {
const now = new Date();
const versionReqs = requirements.filter((r) => r.versionId === version.id);
const versionReqIds = new Set(versionReqs.map((r) => r.id));
const versionPlans = plans.filter((p) => p.versionId === version.id);
const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId));
const versionTCs = testCases.filter((c) => c.versionId === version.id);
const versionBugs = bugs.filter((b) => b.versionId === version.id);
const versionOT = records.filter((r) => r.versionId === version.id);
const stageEffortMetrics = calcStageEffortMetrics({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
now,
});
const effortTotals = calcVersionOverviewEffortTotals({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
overtimeRecords: versionOT,
now,
});
const personalRanking = calcPersonalEffortRanking({
plans: versionPlans,
devTasks: versionDevTasks,
testCases: versionTCs,
bugs: versionBugs,
overtimeRecords: versionOT,
now,
});
const bugRanking = calcBugSeverityRanking(versionBugs);
// 人员加班排名
const personOT: Record<string, number> = {};
versionOT.forEach((r) => { personOT[r.person] = (personOT[r.person] || 0) + r.duration; });
const otRanking = Object.entries(personOT).sort((a, b) => b[1] - a[1]).map(([name, hours]) => ({ name, hours: Math.round(hours * 10) / 10 }));
// 加班原因占比
const reasonMap: Record<string, number> = {};
versionOT.forEach((r) => { reasonMap[r.reasonId] = (reasonMap[r.reasonId] || 0) + r.duration; });
const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]);
const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1;
return (
<div className="space-y-4">
{/* 1. 风险详情 - 最上方 */}
{riskTags.length > 0 && (
<div className="rounded-xl border border-orange-200 bg-orange-50/40 p-4 max-h-[200px] overflow-y-auto">
<div className="flex items-center gap-2 mb-3">
<span className="text-[12px] font-semibold text-orange-700"></span>
<span className="text-[10px] text-orange-600"> {healthScore} · {HEALTH_LEVEL_LABEL[healthLevel]}</span>
</div>
<div className="space-y-2">
{riskTags.map((tag) => (
<div key={tag.key} className="flex gap-2.5 pb-2 border-b border-orange-100 last:border-b-0 last:pb-0">
<span className={`shrink-0 inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium h-fit mt-0.5 ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
<div className="flex-1 space-y-0.5 text-[11px]">
{tag.reason && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.reason}</div>}
{tag.suggestion && <div className="text-[var(--ink-soft)]"><span className="text-[var(--ink-muted)]"></span>{tag.suggestion}</div>}
</div>
</div>
))}
</div>
</div>
)}
{/* 2. 阶段概览 */}
{(() => {
const researchPlans = versionPlans.filter((p) => p.type === 'research');
const productPlans = versionPlans.filter((p) => p.type === 'product');
const uiPlans = versionPlans.filter((p) => p.type === 'ui');
const calcGroupProgress = (group: typeof versionPlans, type: 'research' | 'product' | 'ui') => {
if (group.length === 0) return 0;
// 分子=已完成子任务数,分母=所有plan的子任务总数
// completed 的 plan其子任务全算已完成
let totalItems = 0;
let doneItems = 0;
for (const p of group) {
if (type === 'research') {
const tasks = p.tasks || [];
const count = Math.max(tasks.length, 1);
totalItems += count;
if (p.status === 'completed') {
doneItems += count;
} else {
doneItems += tasks.filter((t) => t.status === 'completed').length;
}
} else {
const summary = getRequirementCoverageSummary(p);
const count = Math.max(summary.total, 1);
totalItems += count;
if (p.status === 'completed') {
doneItems += count;
} else {
doneItems += summary.completed;
}
}
}
return totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
};
const getPlanStatus = (group: typeof versionPlans): 'idle' | 'active' | 'done' => {
if (group.length === 0) return 'idle';
if (group.every((p) => p.status === 'completed')) return 'done';
if (group.some((p) => p.status === 'in_progress')) return 'active';
return 'idle';
};
type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done'; actualHours?: number; aiEstimateHours?: number };
const stageProgress: Partial<Record<string, StageProgressItem>> = {};
stageProgress['requirement'] = {
percent: calcGroupProgress(researchPlans, 'research'),
status: getPlanStatus(researchPlans),
...stageEffortMetrics.requirement,
};
stageProgress['product_design'] = {
percent: calcGroupProgress(productPlans, 'product'),
status: getPlanStatus(productPlans),
...stageEffortMetrics.product_design,
};
stageProgress['ui_design'] = {
percent: calcGroupProgress(uiPlans, 'ui'),
status: getPlanStatus(uiPlans),
...stageEffortMetrics.ui_design,
};
// 开发阶段
const allSubmitted = versionDevTasks.length > 0 && versionDevTasks.every((t) => t.status === 'submitted');
const hasActiveDevTask = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
stageProgress['dev'] = {
percent: versionDevTasks.length > 0 ? calcDevTaskProgress(versionDevTasks) : 0,
status: versionDevTasks.length === 0 ? 'idle' : allSubmitted ? 'done' : hasActiveDevTask ? 'active' : 'idle',
...stageEffortMetrics.dev,
};
// 测试阶段
const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
const testPercent = versionTCs.length > 0 ? Math.round((executed / versionTCs.length) * 100) : 0;
const allPassed = versionTCs.length > 0 && versionTCs.every((c) => c.status === 'passed');
const hasRunning = versionTCs.some((c) => c.status === 'running');
stageProgress['testing'] = {
percent: testPercent,
status: versionTCs.length === 0 ? 'idle' : allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle',
...stageEffortMetrics.testing,
};
// BUG 阶段已关闭Bug / 总Bug
const closedBugs = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
const bugPercent = versionBugs.length > 0 ? Math.round((closedBugs / versionBugs.length) * 100) : 0;
const allClosed = versionBugs.length > 0 && versionBugs.every((b) => b.status === 'closed' || b.status === 'rejected');
stageProgress['bug'] = {
percent: bugPercent,
status: versionBugs.length === 0 ? 'idle' : allClosed ? 'done' : 'active',
...stageEffortMetrics.bug,
};
return <CapsuleStages stageProgress={stageProgress as any} />;
})()}
{/* 3. 时间与投入概览 + 参与人员 + 相关链接 */}
<div className="space-y-4">
{(() => {
const startDates: string[] = [];
versionPlans.forEach((p) => {
if (p.actualStartAt) startDates.push(p.actualStartAt);
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= now) startDates.push(p.startTime);
});
versionDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
versionTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (version.startDate ?? null);
const endDates: string[] = [];
versionPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
versionDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
versionTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
versionBugs.forEach((b) => {
if (b.closedAt) endDates.push(b.closedAt);
else if (b.resolvedAt) endDates.push(b.resolvedAt);
else if ((b.status === 'closed' || b.status === 'rejected') && b.updatedAt) endDates.push(b.updatedAt);
});
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
const deadline = version.expectedReleaseDate;
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0;
if (deadline && isTerminalVersion && actualEndIso) {
const endDate = new Date(actualEndIso);
const deadlineDate = new Date(deadline);
endDate.setHours(0, 0, 0, 0);
deadlineDate.setHours(0, 0, 0, 0);
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
}
const metrics = [
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
{ label: '预计截止', value: deadline ?? '未设置' },
{ label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
{ label: '加班时长', value: formatActualDuration(effortTotals.overtimeHours), sub: '来自加班记录', tone: 'orange' as const },
];
return (
<div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
{metrics.map((item) => (
<OverviewMetric
key={item.label}
label={item.label}
value={item.value}
icon={item.icon}
sub={item.sub}
tone={item.tone}
/>
))}
</div>
{overdueDays > 0 && (
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
{overdueDays}
</div>
)}
</div>
);
})()}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="text-[11px] text-[var(--ink-muted)] font-medium"></div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowRecommendModal(true)}
disabled={!recommendationDataReady}
className="flex items-center gap-1 text-[11px] font-medium text-violet-600 hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline"
>
<Sparkles className="h-3 w-3" />{recommendationDataReady ? 'AI推荐' : '加载中'}
</button>
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
<Settings className="h-3 w-3" />
</button>
</div>
</div>
{(() => {
const membersList = version.members ?? [];
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI', frontend: '前端', backend: '后端', testing: '测试' };
if (membersList.length === 0) return <span className="text-[12px] text-[var(--ink-muted)]"></span>;
return (
<div className="flex flex-wrap gap-1.5">
{membersList.map((m, i) => (
<span key={`${m.name}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--ink-soft)]">
<span className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span>
<span className="font-medium text-[var(--ink)]">{m.name}</span>
</span>
))}
</div>
);
})()}
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{(() => {
const vPlansCompleted = versionPlans.filter((p) => p.status === 'completed' && p.resultUrl);
const typeLabel: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
const grouped = ['research', 'product', 'ui'].map((type) => ({
type,
label: typeLabel[type],
items: vPlansCompleted.filter((p) => p.type === type),
})).filter((g) => g.items.length > 0);
if (grouped.length === 0) {
return <span className="text-[12px] text-[var(--ink-muted)]"></span>;
}
return (
<div className="space-y-3">
{grouped.map((g) => (
<div key={g.type}>
<div className="text-[10px] text-[var(--ink-muted)] mb-1">{g.label}</div>
<div className="space-y-1.5">
{g.items.map((p) => (
<a key={p.id} href={p.resultUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 text-[12px] text-[var(--accent)] hover:underline">
{p.resultType === 'file' ? <FileText className="h-3 w-3" /> : <Link2 className="h-3 w-3" />}
<span className="truncate">{p.resultTitle || p.resultFileName || p.title}</span>
</a>
))}
</div>
</div>
))}
</div>
);
})()}
</div>
</div>
</div>
{/* 5. 加班时长排名 + 加班原因占比 */}
<div className="grid grid-cols-2 gap-4">
{/* 参与人员加班排名 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{otRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{otRanking.slice(0, 8).map((item, i) => (
<div key={item.name} className="flex items-center gap-2">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="flex-1 text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.hours}h</span>
</div>
))}
</div>
)}
</div>
{/* 加班原因占比 - 饼图 */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{reasonRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="flex items-center gap-4">
{/* SVG 环形饼图 */}
<svg viewBox="0 0 100 100" className="h-28 w-28 shrink-0">
{(() => {
const colors = ['#3b82f6', '#f97316', '#8b5cf6', '#10b981', '#ef4444', '#f59e0b', '#6366f1', '#ec4899'];
let cumPercent = 0;
return reasonRanking.map(([reasonId, hours], i) => {
const percent = hours / reasonTotal;
const startAngle = cumPercent * 360;
cumPercent += percent;
const endAngle = cumPercent * 360;
const largeArc = percent > 0.5 ? 1 : 0;
const startRad = ((startAngle - 90) * Math.PI) / 180;
const endRad = ((endAngle - 90) * Math.PI) / 180;
const x1 = 50 + 40 * Math.cos(startRad);
const y1 = 50 + 40 * Math.sin(startRad);
const x2 = 50 + 40 * Math.cos(endRad);
const y2 = 50 + 40 * Math.sin(endRad);
if (percent >= 1) {
return <circle key={reasonId} cx="50" cy="50" r="40" fill={colors[i % colors.length]} />;
}
return (
<path
key={reasonId}
d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`}
fill={colors[i % colors.length]}
/>
);
});
})()}
<circle cx="50" cy="50" r="22" fill="var(--bg-card)" />
</svg>
{/* 图例 */}
<div className="space-y-1.5 flex-1">
{reasonRanking.map(([reasonId, hours], i) => {
const colors = ['#3b82f6', '#f97316', '#8b5cf6', '#10b981', '#ef4444', '#f59e0b', '#6366f1', '#ec4899'];
const percent = Math.round((hours / reasonTotal) * 100);
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
return (
<div key={reasonId} className="flex items-center gap-2 text-[11px]">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: colors[i % colors.length] }} />
<span className="flex-1 text-[var(--ink-soft)] truncate">{reasonName}</span>
<span className="tabular-nums text-[var(--ink-muted)]">{percent}%</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* 6. 需求变更统计 */}
{(() => {
const changeReqs = requirements.filter((r) => r.versionId === version.id && r.reqType === 'change');
if (changeReqs.length === 0) return null;
// 变更人员排名
const personChange: Record<string, number> = {};
changeReqs.forEach((r) => { const by = r.changeBy || r.creator; personChange[by] = (personChange[by] || 0) + 1; });
const changePersonRanking = Object.entries(personChange).sort((a, b) => b[1] - a[1]);
// 变更原因占比
const reasonCount: Record<string, number> = {};
changeReqs.forEach((r) => { if (r.changeReason) reasonCount[r.changeReason] = (reasonCount[r.changeReason] || 0) + 1; });
const changeReasonRanking = Object.entries(reasonCount).sort((a, b) => b[1] - a[1]);
const reasonTotal = changeReqs.length;
const colors = ['#f97316', '#3b82f6', '#8b5cf6', '#10b981'];
return (
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{changePersonRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2">
{changePersonRanking.map(([name, count], i) => (
<div key={name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-[var(--ink-muted)] w-4">{i + 1}</span>
<span className="text-[12px] text-[var(--ink)]">{name}</span>
</div>
<span className="text-[12px] font-medium tabular-nums text-orange-600">{count} </span>
</div>
))}
</div>
)}
</div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="flex items-center gap-4">
<svg viewBox="0 0 100 100" className="h-24 w-24 shrink-0">
{(() => {
let cumPercent = 0;
return changeReasonRanking.map(([reasonId, count], i) => {
const percent = count / reasonTotal;
const startAngle = cumPercent * 360;
cumPercent += percent;
const endAngle = cumPercent * 360;
const largeArc = percent > 0.5 ? 1 : 0;
const startRad = ((startAngle - 90) * Math.PI) / 180;
const endRad = ((endAngle - 90) * Math.PI) / 180;
const x1 = 50 + 40 * Math.cos(startRad);
const y1 = 50 + 40 * Math.sin(startRad);
const x2 = 50 + 40 * Math.cos(endRad);
const y2 = 50 + 40 * Math.sin(endRad);
if (percent >= 1) return <circle key={reasonId} cx="50" cy="50" r="40" fill={colors[i % colors.length]} />;
return <path key={reasonId} d={`M 50 50 L ${x1} ${y1} A 40 40 0 ${largeArc} 1 ${x2} ${y2} Z`} fill={colors[i % colors.length]} />;
});
})()}
<circle cx="50" cy="50" r="20" fill="var(--bg-card)" />
</svg>
<div className="space-y-1.5 flex-1">
{changeReasonRanking.map(([reasonId, count], i) => (
<div key={reasonId} className="flex items-center gap-2 text-[11px]">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ backgroundColor: colors[i % colors.length] }} />
<span className="flex-1 text-[var(--ink-soft)] truncate">{CHANGE_REASON_LABEL[reasonId as keyof typeof CHANGE_REASON_LABEL] || reasonId}</span>
<span className="tabular-nums text-[var(--ink-muted)]">{Math.round((count / reasonTotal) * 100)}%</span>
</div>
))}
</div>
</div>
</div>
</div>
);
})()}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* 个人耗时排名 */}
{(() => {
const maxHours = personalRanking[0]?.total || 1;
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
<div className="flex items-center gap-3 text-[10px] text-[var(--ink-muted)]">
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-[var(--accent)]" /></span>
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-orange-500" /></span>
</div>
</div>
{personalRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2.5">
{personalRanking.slice(0, 8).map((item, i) => {
const totalWidth = (item.total / maxHours) * 100;
const actualWidth = item.total > 0 ? (item.actualHours / item.total) * 100 : 0;
const overtimeWidth = item.total > 0 ? (item.overtimeHours / item.total) * 100 : 0;
return (
<div key={item.name}>
<div className="mb-1 flex items-center gap-2">
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-[var(--accent-soft)] text-[var(--accent)]' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.name}</span>
<span className="shrink-0 text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{formatActualDuration(item.total)}</span>
</div>
<div className="ml-7 h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="flex h-full overflow-hidden rounded-full" style={{ width: `${totalWidth}%` }}>
{item.actualHours > 0 && <div className="h-full bg-[var(--accent)]" style={{ width: `${actualWidth}%` }} />}
{item.overtimeHours > 0 && <div className="h-full bg-orange-500" style={{ width: `${overtimeWidth}%` }} />}
</div>
</div>
<div className="ml-7 mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--ink-muted)]">
<span> {formatActualDuration(item.actualHours)}</span>
<span> {formatActualDuration(item.overtimeHours)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
})()}
{/* Bug 数排名 */}
{(() => {
const maxBugCount = bugRanking[0]?.total || 1;
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] font-medium text-[var(--ink-muted)]">Bug </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-[var(--ink-muted)]">
{BUG_SEVERITY_SEGMENTS.map((segment) => (
<span key={segment.key} className="inline-flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${segment.color}`} />
{segment.label}
</span>
))}
</div>
</div>
{bugRanking.length === 0 ? (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
) : (
<div className="space-y-2.5">
{bugRanking.slice(0, 8).map((item, i) => {
const totalWidth = (item.total / maxBugCount) * 100;
return (
<div key={item.assigneeId}>
<div className="mb-1 flex items-center gap-2">
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${i < 3 ? 'bg-red-50 text-red-600' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.assigneeId}</span>
<span className="shrink-0 text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total} </span>
</div>
<div className="ml-7 h-2 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="flex h-full overflow-hidden rounded-full" style={{ width: `${totalWidth}%` }}>
{BUG_SEVERITY_SEGMENTS.map((segment) => {
const count = item[segment.key];
if (count <= 0) return null;
return (
<div
key={segment.key}
className={`h-full ${segment.color}`}
style={{ width: `${(count / item.total) * 100}%` }}
title={`${segment.label} ${count}`}
/>
);
})}
</div>
</div>
<div className="ml-7 mt-1 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--ink-muted)]">
{BUG_SEVERITY_SEGMENTS.map((segment) => (
<span key={segment.key}>{segment.label} {item[segment.key]}</span>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})()}
</div>
{/* 健康趋势 - 暂时不显示 */}
{/* <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 max-w-[320px]">
<span className="text-[11px] font-medium text-[var(--ink-muted)] mb-2 block">健康趋势</span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div> */}
</div>
);
})()
) : activeTab === 'requirements' ? (
<VersionRequirementsTab
versionId={version.id}
projectId={version.projectId}
requirements={requirements}
devTasks={devTasks}
versionMembers={version.members ?? []}
currentUserName={user?.name ?? ''}
onLink={(ids, addedBy) => {
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
}}
onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
onCreateChange={(data) => {
createRequirement({
...data,
productId: version.productId,
projectId: version.projectId,
versionId: version.id,
sourceType: 'internal',
sourceTarget: '',
platforms: [],
typeId: '',
status: 'adopted',
priority: 'P2',
effort: 'M',
creator: user?.name ?? '',
addedToVersionBy: user?.name ?? '',
reqType: 'change',
} as any);
}}
/>
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
return (
<PlanTab
plans={plans}
versionId={version.id}
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''}
planType={pt}
versionMembers={version.members ?? []}
linkedRequirements={projectAdoptedReqs}
allRequirements={requirements}
onCreate={(data) => {
createPlan(data);
if ((pt === 'product') && data.linkedRequirementIds?.length) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
// 同步版本状态:计划开始时间<=今天,版本进入对应阶段
const today = new Date().toISOString().slice(0, 10);
if (data.startTime <= today && (version.status === 'planned' || version.status === 'developing')) {
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
updateVersion(version.productId, version.id, { status: 'developing', currentStage: stageMap[pt] });
}
}}
onUpdate={(id, data) => {
updatePlan(id, data);
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
}
}}
onComplete={completePlan}
onDelete={deletePlan}
/>
);
})()
) : activeTab === 'tasks' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return (
<DevTaskTab
versionId={version.id}
requirementIds={versionReqs.map((r) => r.id)}
versionDeadline={version.expectedReleaseDate ?? undefined}
/>
);
})()
) : activeTab === 'testcases' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
})()
) : activeTab === 'bugs' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
})()
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
<span className="text-[13px] text-[var(--ink-muted)]"></span>
</div>
)}
</div>
{showRecommendModal && (
<MemberRecommendationModal
groups={memberRecommendationGroups}
members={version.members ?? []}
onAdd={(selectedNames) => {
const recommendations = memberRecommendationGroups.flatMap((group) => group.items);
const newMembers = addRecommendedVersionMembers(version.members ?? [], selectedNames, recommendations);
updateVersion(version.productId, version.id, { members: newMembers });
setShowRecommendModal(false);
}}
onClose={() => setShowRecommendModal(false)}
/>
)}
{/* 参与人员设置弹窗 */}
{showMemberModal && (
<MemberSettingModal
members={version.members ?? []}
allMembers={memberCandidates}
onSave={(newMembers) => {
updateVersion(version.productId, version.id, { members: newMembers });
setShowMemberModal(false);
}}
onClose={() => setShowMemberModal(false)}
/>
)}
</div>
);
}
function OverviewMetric({ label, value, icon, sub, tone }: {
label: string;
value: string | number;
icon?: JSX.Element;
sub?: string;
tone?: 'accent' | 'orange';
}) {
const valueColor = tone === 'orange' ? 'text-orange-600' : tone === 'accent' ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
return (
<div className="min-w-0 border-b border-r border-[var(--line-soft)] p-3.5">
<div className="flex items-center gap-1.5 text-[11px] font-medium text-[var(--ink-muted)]">
{icon && <span className="shrink-0 text-[var(--ink-muted)]">{icon}</span>}
<span>{label}</span>
</div>
<div className={`mt-1 truncate text-[14px] font-semibold tabular-nums ${valueColor}`} title={String(value)}>
{value}
</div>
{sub && <div className="mt-1 truncate text-[10px] text-[var(--ink-muted)]">{sub}</div>}
</div>
);
}
function formatRecommendationHours(hours: number): string {
if (hours <= 0) return '0h';
return `${Math.round(hours * 10) / 10}h`;
}
function formatAvailableDays(days: number): string {
if (days <= 0) return '今天';
return `${days}`;
}
function getRecommendationScoreClass(score: number): string {
if (score >= 80) return 'bg-emerald-50 text-emerald-700 border-emerald-100';
if (score >= 65) return 'bg-blue-50 text-blue-700 border-blue-100';
if (score >= 50) return 'bg-amber-50 text-amber-700 border-amber-100';
return 'bg-red-50 text-red-700 border-red-100';
}
function getRecommendationBarClass(score: number): string {
if (score >= 80) return 'bg-emerald-500';
if (score >= 65) return 'bg-blue-500';
if (score >= 50) return 'bg-amber-500';
return 'bg-red-500';
}
function getInitialRecommendationSelection(groups: MemberRecommendationGroup[]): Set<string> {
return new Set(getDefaultRecommendedMemberNames(groups));
}
function MemberRecommendationModal({ groups, members, onAdd, onClose }: {
groups: MemberRecommendationGroup[];
members: { role: Role; name: string }[];
onAdd: (selectedNames: string[]) => void;
onClose: () => void;
}) {
const allItems = groups.flatMap((group) => group.items);
const [selectedNames, setSelectedNames] = useState<Set<string>>(() => getInitialRecommendationSelection(groups));
const hasHistoricalSignal = allItems.some((item) => item.confidence === 'high');
const toggleName = (name: string) => {
setSelectedNames((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleAdd = () => {
if (selectedNames.size === 0) return;
onAdd(Array.from(selectedNames));
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="flex max-h-[calc(100vh-48px)] w-[min(860px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[15px] font-semibold text-[var(--ink)]">AI推荐参与人员</h3>
{!hasHistoricalSignal && allItems.length > 0 && (
<span className="rounded-full border border-amber-100 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700"></span>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">
{members.length} {allItems.length} {selectedNames.size}
</p>
</div>
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"><X className="h-4 w-4" /></button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{allItems.length === 0 ? (
<div className="flex min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg)] text-center">
<Sparkles className="mb-3 h-5 w-5 text-[var(--ink-muted)]" />
<div className="text-[13px] font-medium text-[var(--ink)]"></div>
<div className="mt-1 text-[11px] text-[var(--ink-muted)]"></div>
</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<section key={group.role}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold text-[var(--ink)]">{RECOMMENDATION_ROLE_LABEL[group.role]}</span>
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">
{group.requiredCount} / {group.currentCount} / {group.missingCount}
</span>
</div>
<span className="text-[10px] text-[var(--ink-muted)]"> {formatRecommendationHours(group.scopeHours)}</span>
</div>
<div className="space-y-2">
{group.items.map((item) => {
const selected = selectedNames.has(item.name);
const workloadCount = item.metrics.activeTaskCount + item.metrics.activeBugCount;
const stats = [
{ label: '剩余', value: formatRecommendationHours(item.metrics.remainingHours) },
{ label: '可接入', value: formatAvailableDays(item.metrics.availableInDays) },
{ label: '事项', value: `${workloadCount}` },
{ label: '加班', value: formatRecommendationHours(item.metrics.recentOvertimeHours) },
];
return (
<button
key={item.name}
type="button"
onClick={() => toggleName(item.name)}
className={`w-full overflow-hidden rounded-xl border text-left transition-colors ${selected ? 'border-violet-300 bg-violet-50/60' : 'border-[var(--line)] bg-[var(--bg)] hover:border-violet-200 hover:bg-[var(--bg-subtle)]'}`}
>
<div className={`h-1 ${getRecommendationBarClass(item.score)}`} style={{ width: `${item.score}%` }} />
<div className="flex gap-3 px-3 py-3">
<span className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border ${selected ? 'border-violet-600 bg-violet-600 text-white' : 'border-[var(--line)] text-transparent'}`}>
<Check className="h-3 w-3" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[13px] font-semibold text-[var(--ink)]">{item.name}</span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums ${getRecommendationScoreClass(item.score)}`}>{item.score}</span>
<span className="rounded-full bg-[var(--bg-card)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{RECOMMENDATION_CONFIDENCE_LABEL[item.confidence]}</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map((stat) => (
<span key={stat.label} className="rounded-lg bg-[var(--bg-card)] px-2 py-1">
<span className="block text-[10px] text-[var(--ink-muted)]">{stat.label}</span>
<span className="block text-[11px] font-semibold text-[var(--ink)] tabular-nums">{stat.value}</span>
</span>
))}
</div>
{(item.reasons.length > 0 || item.warnings.length > 0 || typeof item.metrics.bugRate === 'number') && (
<div className="mt-2 flex flex-wrap gap-1.5">
{item.reasons.slice(0, 2).map((reason) => (
<span key={reason} className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">{reason}</span>
))}
{typeof item.metrics.bugRate === 'number' && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] text-blue-700">Bug率 {item.metrics.bugRate}</span>
)}
{item.warnings.slice(0, 2).map((warning) => (
<span key={warning} className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
<AlertTriangle className="h-3 w-3" />
{warning}
</span>
))}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
</section>
))}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-4">
<button
type="button"
onClick={() => setSelectedNames(new Set())}
disabled={selectedNames.size === 0}
className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<div className="flex items-center gap-2">
<button onClick={onClose} className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={handleAdd} disabled={selectedNames.size === 0} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-violet-600 px-4 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-45">
<UserPlus className="h-3.5 w-3.5" />
{selectedNames.size}
</button>
</div>
</div>
</div>
</div>
);
}
function MemberSettingModal({ members, allMembers, onSave, onClose }: {
members: { role: Role; name: string }[];
allMembers: { id: string; name: string; departmentName?: string }[];
onSave: (members: { role: Role; name: string }[]) => void;
onClose: () => void;
}) {
const [list, setList] = useState<{ role: Role; name: string }[]>([...members]);
const [query, setQuery] = useState('');
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
const roleOptions: { value: Role; label: string }[] = [
{ value: 'product', label: '产品' },
{ value: 'ui', label: 'UI' },
{ value: 'frontend', label: '前端' },
{ value: 'backend', label: '后端' },
{ value: 'testing', label: '测试' },
];
const candidates = useMemo(
() => filterVersionMemberCandidates(allMembers, list, query),
[allMembers, list, query],
);
const toggleCandidate = (name: string) => {
setSelectedNames((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleSelectAll = () => {
setSelectedNames((prev) => {
const next = new Set(prev);
candidates.forEach((member) => next.add(member.name));
return next;
});
};
const handleAddSelected = () => {
if (selectedNames.size === 0) return;
setList(addVersionMembers(list, selectedNames, allMembers, DEFAULT_VERSION_MEMBER_ROLE));
setSelectedNames(new Set());
setQuery('');
};
const handleRoleChange = (name: string, role: Role) => {
setList(list.map((member) => (member.name === name ? { ...member, role } : member)));
};
const handleRemove = (name: string) => {
setList(list.filter((member) => member.name !== name));
setSelectedNames((prev) => {
const next = new Set(prev);
next.delete(name);
return next;
});
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="flex max-h-[calc(100vh-48px)] w-[min(960px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-[var(--line)] px-6 py-4">
<div>
<h3 className="text-[15px] font-semibold text-[var(--ink)]"></h3>
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">{list.length} </p>
</div>
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"><X className="h-4 w-4" /></button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="flex min-h-[420px] flex-col overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg)]">
<div className="border-b border-[var(--line)] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-[12px] font-semibold text-[var(--ink)]"></div>
<button
type="button"
onClick={handleAddSelected}
disabled={selectedNames.size === 0}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<UserPlus className="h-3.5 w-3.5" />
{selectedNames.size > 0 ? `添加 ${selectedNames.size}` : '添加'}
</button>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索成员"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] pl-9 pr-3 text-[13px] text-[var(--ink)] outline-none transition-colors placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)]"
/>
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-[var(--ink-muted)]">
<span> {candidates.length} </span>
<div className="flex items-center gap-2">
<button type="button" onClick={handleSelectAll} disabled={candidates.length === 0} className="text-[var(--accent)] hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline"></button>
<button type="button" onClick={() => setSelectedNames(new Set())} disabled={selectedNames.size === 0} className="hover:text-[var(--ink-soft)] disabled:cursor-not-allowed disabled:text-[var(--ink-muted)]"></button>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{candidates.length === 0 ? (
<div className="flex h-full min-h-[180px] items-center justify-center rounded-lg border border-dashed border-[var(--line)] text-[12px] text-[var(--ink-muted)]"></div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{candidates.map((member) => {
const selected = selectedNames.has(member.name);
return (
<button
key={member.id}
type="button"
onClick={() => toggleCandidate(member.name)}
className={`flex h-12 items-center justify-between rounded-lg border px-3 text-left transition-colors ${selected ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)]'}`}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-medium">{member.name}</span>
{member.departmentName && <span className="mt-0.5 block truncate text-[10px] text-[var(--ink-muted)]">{member.departmentName}</span>}
</span>
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${selected ? 'border-[var(--accent)] bg-[var(--accent)] text-white' : 'border-[var(--line)] text-transparent'}`}>
<Check className="h-3 w-3" />
</span>
</button>
);
})}
</div>
)}
</div>
</section>
<section className="flex min-h-[420px] flex-col overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg)]">
<div className="flex items-center justify-between border-b border-[var(--line)] p-4">
<div className="text-[12px] font-semibold text-[var(--ink)]"></div>
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] tabular-nums text-[var(--ink-muted)]">{list.length} </span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{list.length === 0 ? (
<div className="flex h-full min-h-[180px] items-center justify-center rounded-lg border border-dashed border-[var(--line)] text-[12px] text-[var(--ink-muted)]"></div>
) : (
<div className="space-y-2">
{list.map((member) => (
<div key={member.name} className="flex items-center gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--ink)]">{member.name}</span>
<FilterSelect
value={member.role}
onChange={(value) => handleRoleChange(member.name, value as Role)}
options={roleOptions}
showAllOption={false}
className="w-24 shrink-0"
/>
<button type="button" onClick={() => handleRemove(member.name)} className="rounded-md p-1 text-red-400 hover:bg-red-50 hover:text-red-600">
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</section>
</div>
<div className="flex justify-end gap-2 border-t border-[var(--line)] px-6 py-4">
<button onClick={onClose} className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSave(list)} className="h-8 rounded-lg bg-[var(--accent)] px-4 text-[12px] font-medium text-white"></button>
</div>
</div>
</div>
);
}