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:
Script Generator
2026-06-10 17:07:36 +08:00
parent 2f69bc74cd
commit 2acc9aeaa9
21 changed files with 1278 additions and 323 deletions

View File

@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/useAuthStore';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { isAuthenticated, checkAuth } = useAuthStore();
const [checked, setChecked] = useState(false);
useEffect(() => {
checkAuth();
setChecked(true);
}, [checkAuth]);
useEffect(() => {
if (!checked) return;
if (!isAuthenticated && pathname !== '/login') {
router.replace('/login');
}
if (isAuthenticated && pathname === '/login') {
router.replace('/products');
}
}, [checked, isAuthenticated, pathname, router]);
if (!checked) return null;
if (!isAuthenticated && pathname !== '/login') return null;
return <>{children}</>;
}

View File

@@ -0,0 +1,23 @@
'use client';
import { usePathname } from 'next/navigation';
import { Sidebar } from '@/components/layout/Sidebar';
import { AuthGuard } from '@/components/AuthGuard';
export function LayoutShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isLoginPage = pathname === '/login';
return (
<AuthGuard>
{isLoginPage ? (
<>{children}</>
) : (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
)}
</AuthGuard>
);
}

View File

@@ -10,6 +10,7 @@ interface RequirementModalProps {
initial?: Requirement | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId: string }[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
@@ -36,6 +37,7 @@ export function RequirementModal({
initial,
products,
projects,
versions,
sourceTargets,
types,
platforms,
@@ -56,6 +58,7 @@ export function RequirementModal({
const [effort, setEffort] = useState<Effort>('M');
const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState('');
const [versionId, setVersionId] = useState('');
const [platformDropOpen, setPlatformDropOpen] = useState(false);
@@ -64,6 +67,11 @@ export function RequirementModal({
[projects, productId],
);
const filteredVersions = useMemo(
() => projectId ? versions.filter((v) => v.projectId === projectId) : [],
[versions, projectId],
);
useEffect(() => {
if (initial) {
setTitle(initial.title);
@@ -72,6 +80,7 @@ export function RequirementModal({
setSourceTarget(initial.sourceTarget || '');
setProductId(initial.productId || '');
setProjectId(initial.projectId || '');
setVersionId(initial.versionId || '');
setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2');
@@ -85,6 +94,7 @@ export function RequirementModal({
setSourceTarget('');
setProductId('');
setProjectId('');
setVersionId('');
setSelectedPlatforms([]);
setTypeId('');
setPriority('P2');
@@ -113,6 +123,7 @@ export function RequirementModal({
sourceTarget: sourceTarget || undefined,
productId: productId || undefined,
projectId: projectId || undefined,
versionId: versionId || undefined,
platforms: selectedPlatforms,
typeId: typeId || undefined,
priority,
@@ -226,15 +237,15 @@ export function RequirementModal({
</div>
</div>
{/* 4. 所属产品 → 项目 */}
<div className="grid grid-cols-2 gap-3">
{/* 4. 所属产品 → 项目 → 版本 */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={productId}
onChange={(e) => { setProductId(e.target.value); setProjectId(''); }}
onChange={(e) => { setProductId(e.target.value); setProjectId(''); setVersionId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
@@ -249,7 +260,7 @@ export function RequirementModal({
</label>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
onChange={(e) => { setProjectId(e.target.value); setVersionId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
@@ -258,6 +269,21 @@ export function RequirementModal({
))}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={versionId}
onChange={(e) => setVersionId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredVersions.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
</div>
{/* 5. 支持端 (multi-select) */}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react';
import type { VersionPlan } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration } from '@/lib/version-plan';
interface Props {
plans: VersionPlan[];
versionId: string;
versionDeadline?: string;
currentUserName: string;
planType: 'research' | 'product' | 'ui';
linkedRequirements?: { id: string; title: string; code: string; productOwner?: string }[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
onDelete: (id: string) => void;
}
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
const STATUS_STYLE = {
pending: 'bg-zinc-100 text-zinc-600',
in_progress: 'bg-blue-50 text-blue-600 border-blue-200',
completed: 'bg-green-50 text-green-700 border-green-200',
};
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
export function PlanTab({ plans, versionId, versionDeadline, currentUserName, planType, linkedRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
const typePlans = plans.filter((p) => p.versionId === versionId && p.type === planType);
const totalDuration = calcTotalDuration(typePlans);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} {TYPE_LABEL[planType]}</span>
<span className="text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]"><span className="font-medium text-red-500">{versionDeadline}</span></span>}
</div>
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
{typePlans.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
) : (
<div className="space-y-3">
{typePlans.map((plan) => {
const dur = plan.completedAt
? calcPlanDuration(plan.startTime, plan.completedAt)
: calcPlanDuration(plan.startTime, plan.endTime);
const durText = formatDuration(dur.days, dur.hours);
return (
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[plan.status]}`}>
{STATUS_LABEL[plan.status]}
</span>
</div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{plan.owner}</span>
<span>{plan.startTime.slice(0, 10)} {plan.endTime.slice(0, 10)}</span>
{plan.completedAt && <span className="text-green-600">{plan.completedAt.slice(0, 10)}</span>}
<span className="font-medium text-[var(--ink)]"> {durText}</span>
</div>
{plan.overdueReason && (
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">{plan.overdueReason}</div>
)}
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
<div className="flex flex-wrap gap-1.5 mt-2">
{plan.linkedRequirementIds.map((rid) => {
const req = linkedRequirements.find((r) => r.id === rid);
return req ? (
<span key={rid} className="inline-flex items-center gap-1 rounded bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] text-[var(--ink-soft)]">
{req.code} {req.title.slice(0, 12)}{req.title.length > 12 ? '...' : ''}
{req.productOwner && <span className="text-[var(--ink-muted)]">({req.productOwner})</span>}
</span>
) : null;
})}
</div>
)}
{plan.status === 'completed' && plan.resultUrl && (
<div className="flex items-center gap-1.5 mt-2">
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</div>
<div className="flex items-center gap-1 ml-3">
{plan.status !== 'completed' && (
<>
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setEditingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(plan.id)} className="h-7 w-7 flex items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{(showCreateModal || editingPlan) && (
<PlanFormModal
initial={editingPlan}
planType={planType}
versionId={versionId}
versionDeadline={versionDeadline}
currentUserName={currentUserName}
linkedRequirements={linkedRequirements}
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
onSubmit={(data) => {
if (editingPlan) onUpdate(editingPlan.id, data);
else onCreate(data as any);
setShowCreateModal(false);
setEditingPlan(null);
}}
/>
)}
{completingPlan && (
<CompleteModal
onClose={() => setCompletingPlan(null)}
onSubmit={(result) => { onComplete(completingPlan.id, result); setCompletingPlan(null); }}
/>
)}
</div>
);
}
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, onClose, onSubmit }: {
initial: VersionPlan | null;
planType: 'research' | 'product' | 'ui';
versionId: string;
versionDeadline?: string;
currentUserName: string;
linkedRequirements?: { id: string; title: string; code: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const now = new Date().toISOString().slice(0, 10);
const [title, setTitle] = useState(initial?.title ?? '');
const [owner] = useState(initial?.owner ?? currentUserName);
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
const showReqSelect = planType === 'product' || planType === 'ui';
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !endTime) return;
if (isOverdue && !overdueReason.trim()) return;
onSubmit({
versionId,
type: planType,
title: title.trim(),
owner: owner.trim() || currentUserName,
startTime,
endTime,
status: initial?.status ?? 'pending',
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
remark: remark.trim() || undefined,
overdueReason: isOverdue ? overdueReason.trim() : undefined,
addedBy: currentUserName,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑' : '新建'}{TYPE_LABEL[planType]}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="date" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input type="date" value={endTime} onChange={(e) => setEndTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
</div>
{versionDeadline && (
<div className="text-[11px] text-[var(--ink-muted)] -mt-1"><span className="text-red-500 font-medium">{versionDeadline}</span></div>
)}
{isOverdue && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 space-y-2">
<div className="text-[12px] text-red-700 font-medium"> </div>
<textarea
value={overdueReason}
onChange={(e) => setOverdueReason(e.target.value)}
rows={2}
placeholder="例如:技术难点超预期、上游交付延误..."
required
className="w-full rounded-lg border border-red-200 bg-white px-3 py-2 text-[13px] focus:border-red-400 focus:outline-none resize-none"
/>
</div>
)}
{showReqSelect && linkedRequirements && linkedRequirements.length > 0 && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="max-h-[120px] overflow-y-auto rounded-lg border border-[var(--line)] p-2 space-y-1">
{linkedRequirements.map((req) => (
<label key={req.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-[var(--bg-subtle)] cursor-pointer text-[12px]">
<input type="checkbox" checked={selectedReqs.has(req.id)} onChange={() => { const s = new Set(selectedReqs); if (s.has(req.id)) s.delete(req.id); else s.add(req.id); setSelectedReqs(s); }} className="h-3.5 w-3.5 rounded" />
<span className="text-[var(--ink-muted)] font-mono">{req.code}</span>
<span className="text-[var(--ink)] truncate">{req.title}</span>
</label>
))}
</div>
</div>
)}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
</div>
</form>
</div>
</div>
);
}
function CompleteModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileData(reader.result as string);
reader.readAsDataURL(file);
};
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
</div>
{resultType === 'link' ? (
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import { Plus, X, Search } from 'lucide-react';
import type { Requirement } from '@/lib/requirement';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
interface Props {
versionId: string;
projectId: string;
requirements: Requirement[];
onLink: (reqIds: string[], addedBy: string) => void;
onUnlink: (reqId: string) => void;
currentUserName: string;
}
export function VersionRequirementsTab({ versionId, projectId, requirements, onLink, onUnlink, currentUserName }: Props) {
const [showAddModal, setShowAddModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
const availableReqs = requirements.filter((r) => r.projectId === projectId && !r.versionId && r.status === 'adopted');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} </span>
<button onClick={() => setShowAddModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
</div>
{linkedReqs.length === 0 ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
</div>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<table className="w-full text-left text-[13px]">
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right"></th>
</tr>
</thead>
<tbody>
{linkedReqs.map((req) => (
<tr key={req.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
<td className="px-4 py-3 font-mono text-[12px] text-[var(--ink-muted)]">{req.code}</td>
<td className="px-4 py-3 font-medium text-[var(--ink)]">{req.title}</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)] max-w-[200px] truncate">{req.description || '-'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${REQ_STATUS_COLOR[req.status]}`}>
{REQ_STATUS_LABEL[req.status]}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '-'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showAddModal && (
<AddRequirementModal
available={availableReqs}
onClose={() => setShowAddModal(false)}
onConfirm={(ids) => { onLink(ids, currentUserName); setShowAddModal(false); }}
/>
)}
</div>
);
}
function AddRequirementModal({ available, onClose, onConfirm }: {
available: Requirement[];
onClose: () => void;
onConfirm: (ids: string[]) => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const filtered = available.filter((r) =>
r.title.includes(search) || r.code.includes(search)
);
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
setSelected(next);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] flex flex-col max-h-[70vh]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-3 border-b border-[var(--line)]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--ink-muted)]" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索需求编号或标题" className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-9 pr-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (
<p className="text-center text-[12px] text-[var(--ink-muted)] py-8"></p>
) : (
<div className="space-y-1.5">
{filtered.map((req) => (
<label key={req.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-[var(--bg-subtle)] cursor-pointer">
<input type="checkbox" checked={selected.has(req.id)} onChange={() => toggle(req.id)} className="h-4 w-4 rounded border-[var(--line)] text-[var(--accent)]" />
<span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{req.code}</span>
<span className="text-[12px] text-[var(--ink)] flex-1 truncate">{req.title}</span>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--line)]">
<span className="text-[11px] text-[var(--ink-muted)]"> {selected.size} </span>
<div className="flex gap-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onConfirm(Array.from(selected))} disabled={selected.size === 0} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}