feat: 实现需求管理、加班记录、成员/角色管理模块

- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择
- 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出
- 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置
- 角色管理:卡片列表、系统角色保护、CRUD
- 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页
- 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-09 18:16:18 +08:00
parent 24ba61f929
commit 9a0b16a8f1
36 changed files with 4355 additions and 272 deletions

View File

@@ -0,0 +1,248 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { getVersionDetail } from '@/lib/derive';
import { STAGES } from '@/lib/stage';
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
import { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips';
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health';
const PRIORITY_STYLE: Record<string, string> = {
P0: 'bg-red-500/10 text-red-600',
P1: 'bg-orange-500/10 text-orange-600',
P2: 'bg-blue-500/10 text-blue-600',
P3: 'bg-zinc-100 text-zinc-600',
P4: 'bg-zinc-100 text-zinc-500',
};
const TABS = [
{ key: 'overview', label: '概览' },
{ key: 'requirements', label: '需求' },
{ key: 'tasks', label: '开发任务' },
{ key: 'testcases', label: '测试用例' },
{ key: 'bugs', label: 'Bug' },
];
export default function VersionDetailPage() {
const params = useParams();
const router = useRouter();
const versionId = params.id as string;
const { overview, fetchOverview } = useProductStore();
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const elapsedDays = useMemo(() => {
if (!version?.startDate) return 0;
const start = new Date(version.startDate);
start.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}, [version?.startDate]);
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 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 === 'developing' || version.status === 'planned') {
buttons.push({ label: '暂停', action: () => {} });
buttons.push({ label: '关闭', action: () => {}, danger: true });
} else if (version.status === 'paused') {
buttons.push({ label: '恢复', action: () => {} });
buttons.push({ label: '关闭', action: () => {}, 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>
<span className="ml-2 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">
{TABS.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' ? (
<div className="space-y-4">
{/* Tag row */}
<div className="flex flex-wrap items-center gap-2">
{version.priority && (
<span className={`text-[11px] font-semibold px-2 py-0.5 rounded-full ${PRIORITY_STYLE[version.priority] || PRIORITY_STYLE.P3}`}>
{version.priority}
</span>
)}
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
{VERSION_STATUS_LABEL[version.status]}
</span>
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
{healthScore} {HEALTH_LEVEL_LABEL[healthLevel]}
</span>
{riskTags.map((tag) => (
<span key={tag.key} className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${getTagStyle(tag.severity)}`}>
{tag.label}
</span>
))}
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-0.5 text-[11px] text-[var(--ink-soft)]">
<Package className="h-3 w-3" />{version.productName} / {version.projectName}
</span>
</div>
{/* Capsule stages */}
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
{/* 风险详情 + 趋势健康度低于60时显示 */}
{healthScore < 60 && riskTags.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{/* 左:风险详情(可滚动) */}
<div className="col-span-3 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>
{/* 右:健康趋势 */}
<div className="col-span-1 rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 flex flex-col justify-center">
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1"></span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div>
</div>
)}
{/* 健康度正常时也显示趋势(紧凑) */}
{healthScore >= 60 && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 max-w-[240px]">
<span className="text-[10px] font-medium text-[var(--ink-muted)] mb-1 block"></span>
<HealthTrend data={generateMockTrend(healthScore, 7)} />
</div>
)}
{/* Two-column layout: left info + right links */}
<div className="grid grid-cols-3 gap-4">
{/* Left: 2/3 width */}
<div className="col-span-2 space-y-4">
{/* Date + elapsed */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center gap-4 text-[13px]">
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span>
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
</div>
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div>
</div>
</div>
{/* Members */}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium"></div>
{version.members && version.members.length > 0 ? (
<MemberChips members={version.members} />
) : (
<span className="text-[12px] text-[var(--ink-muted)]"></span>
)}
</div>
</div>
{/* Right: 1/3 width - links card */}
<div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
<div className="space-y-3">
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} />
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} />
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} />
</div>
</div>
</div>
</div>
</div>
) : (
<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>
</div>
);
}
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
return (
<div className="flex items-center gap-2">
<span className="text-[var(--ink-muted)]">{icon}</span>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{label}<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-[12px] text-[var(--ink-muted)]">{label} · </span>
)}
</div>
);
}