1095 lines
63 KiB
TypeScript
1095 lines
63 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { useParams, useRouter } from 'next/navigation';
|
||
import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, 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 { 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 { 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';
|
||
|
||
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 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 } = useMemberStore();
|
||
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);
|
||
|
||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||
|
||
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 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 linked = p.linkedRequirementIds || [];
|
||
const count = Math.max(linked.length, 1);
|
||
totalItems += count;
|
||
if (p.status === 'completed') {
|
||
doneItems += count;
|
||
} else {
|
||
const completed = p.completedRequirementIds || [];
|
||
doneItems += completed.filter((id) => linked.includes(id)).length;
|
||
}
|
||
}
|
||
}
|
||
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 mb-2">
|
||
<div className="text-[11px] text-[var(--ink-muted)] font-medium">参与人员</div>
|
||
<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>
|
||
{(() => {
|
||
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>
|
||
|
||
{/* 参与人员设置弹窗 */}
|
||
{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 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>
|
||
<select
|
||
value={member.role}
|
||
onChange={(e) => handleRoleChange(member.name, e.target.value as Role)}
|
||
className="h-8 w-24 shrink-0 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] text-[var(--ink-soft)] outline-none focus:border-[var(--accent)]"
|
||
>
|
||
{roleOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</select>
|
||
<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>
|
||
);
|
||
}
|