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:
248
apps/web/app/versions/[id]/page.tsx
Normal file
248
apps/web/app/versions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user