feat: 版本详情完整功能 + 数据串联 + 登录模块
- 版本详情:关联需求Tab(从需求池添加已采纳需求/移除释放) - 版本详情:调研/产品方案/UI设计Tab(计划CRUD、完成提交成果、耗时统计) - 版本详情:超期校验(结束日期超版本截止需填写原因) - 版本详情:概览统计卡片(加班时长/排名/原因占比) - 数据串联:产品→项目→版本→需求→加班全链路贯通 - 版本管理:规划中版本可删除,删除释放关联需求 - 数据清理:仅保留翻台宝/值班,需求池10条值班待评审需求 - 修复:列表页overflow裁剪菜单问题(4个页面统一修复) - 新增:登录模块、AuthGuard、VersionPlan store Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ 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 { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { getVersionDetail } from '@/lib/derive';
|
||||
import { STAGES } from '@/lib/stage';
|
||||
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
|
||||
@@ -11,6 +13,12 @@ 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';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
|
||||
import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
|
||||
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
|
||||
import { PlanTab } from '@/components/version/PlanTab';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
|
||||
const PRIORITY_STYLE: Record<string, string> = {
|
||||
P0: 'bg-red-500/10 text-red-600',
|
||||
@@ -22,20 +30,30 @@ const PRIORITY_STYLE: Record<string, string> = {
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: '概览' },
|
||||
{ key: 'requirements', label: '需求' },
|
||||
{ key: 'requirements', label: '关联需求' },
|
||||
{ key: 'research', label: '调研' },
|
||||
{ key: 'product', label: '产品方案' },
|
||||
{ key: 'ui', label: 'UI设计' },
|
||||
{ key: 'tasks', label: '开发任务' },
|
||||
{ key: 'testcases', label: '测试用例' },
|
||||
{ key: 'bugs', label: 'Bug' },
|
||||
{ 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 { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
|
||||
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
|
||||
const { records, fetchRecords } = useOvertimeStore();
|
||||
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
|
||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||
|
||||
@@ -63,12 +81,22 @@ export default function VersionDetailPage() {
|
||||
|
||||
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 });
|
||||
if (version.status === 'planned') {
|
||||
buttons.push({ label: '删除', action: () => {
|
||||
if (confirm('确认删除该版本?关联的需求会回到需求池。')) {
|
||||
// 释放关联需求
|
||||
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
|
||||
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: () => {} });
|
||||
buttons.push({ label: '关闭', action: () => {}, danger: true });
|
||||
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
|
||||
@@ -111,117 +139,224 @@ export default function VersionDetailPage() {
|
||||
{/* 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>
|
||||
(() => {
|
||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||
const versionOT = records.filter((r) => r.versionId === version.id);
|
||||
const totalOTHours = Math.round(versionOT.reduce((sum, r) => sum + r.duration, 0) * 10) / 10;
|
||||
|
||||
{/* Capsule stages */}
|
||||
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
||||
// 人员加班排名
|
||||
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 }));
|
||||
|
||||
{/* 风险详情 + 趋势:健康度低于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>}
|
||||
// 加班原因占比
|
||||
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;
|
||||
|
||||
// Mock 数据占位
|
||||
const mockTaskCount = Math.max(versionReqs.length * 2, 3);
|
||||
const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2);
|
||||
const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1);
|
||||
const mockTaskPending = Math.max(Math.floor(mockTaskCount * 0.4), 1);
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<StatCard label="加班时长" value={`${totalOTHours}h`} accent />
|
||||
<StatCard label="关联需求" value={versionReqs.length} />
|
||||
<StatCard label="待开发任务" value={mockTaskPending} />
|
||||
<StatCard label="BUG总数" value={mockBugTotal} />
|
||||
<StatCard label="未解决BUG" value={mockBugOpen} warn />
|
||||
<StatCard label="参与人员" value={version.members?.length ?? 0} />
|
||||
</div>
|
||||
|
||||
{/* 风险详情(胶囊条上方) */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Capsule stages */}
|
||||
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
||||
|
||||
{/* 双栏:加班排名 + 原因占比 */}
|
||||
<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="space-y-2.5">
|
||||
{reasonRanking.map(([reasonId, hours]) => {
|
||||
const percent = Math.round((hours / reasonTotal) * 100);
|
||||
const reasonName = OVERTIME_REASON_LABEL[reasonId] || reasonId;
|
||||
return (
|
||||
<div key={reasonId}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">{reasonName}</span>
|
||||
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{percent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${percent}%` }} />
|
||||
</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>
|
||||
|
||||
{/* 双栏:日期信息 + 相关链接 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{/* 健康度正常时也显示趋势(紧凑) */}
|
||||
{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>
|
||||
);
|
||||
})()
|
||||
) : activeTab === 'requirements' ? (
|
||||
<VersionRequirementsTab
|
||||
versionId={version.id}
|
||||
projectId={version.projectId}
|
||||
requirements={requirements}
|
||||
currentUserName={user?.name ?? ''}
|
||||
onLink={(ids, addedBy) => {
|
||||
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
|
||||
}}
|
||||
onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })}
|
||||
/>
|
||||
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
||||
(() => {
|
||||
const pt = activeTab as 'research' | 'product' | 'ui';
|
||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||
const linkedReqs = versionReqs.map((r) => ({ id: r.id, title: r.title, code: r.code, productOwner: r.productOwner }));
|
||||
return (
|
||||
<PlanTab
|
||||
plans={plans}
|
||||
versionId={version.id}
|
||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||
currentUserName={user?.name ?? ''}
|
||||
planType={pt}
|
||||
linkedRequirements={pt !== 'research' ? linkedReqs : undefined}
|
||||
onCreate={(data) => {
|
||||
createPlan(data);
|
||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||
}
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<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>
|
||||
@@ -232,6 +367,16 @@ export default function VersionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent, warn }: { label: string; value: string | number; accent?: boolean; warn?: boolean }) {
|
||||
const color = warn ? 'text-red-500' : accent ? 'text-[var(--accent)]' : 'text-[var(--ink)]';
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 text-center">
|
||||
<div className={`text-[18px] font-semibold tabular-nums ${color}`}>{value}</div>
|
||||
<div className="text-[11px] text-[var(--ink-muted)] mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; url?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user