Files
ftb-project-management/apps/web/components/version/VersionRequirementsTab.tsx
Script Generator eef3c8f000 feat(版本): 优化概览与只读状态
关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 18:18:18 +08:00

272 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState } from 'react';
import { Plus, X, Search } from 'lucide-react';
import { FilterSelect } from '@/components/FilterSelect';
import type { Requirement, ChangeReason } from '@/lib/requirement';
import type { DevTask } from '@/lib/dev-task';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, CHANGE_REASON_LABEL } from '@/lib/requirement';
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
interface Props {
versionId: string;
projectId: string;
requirements: Requirement[];
devTasks: DevTask[];
versionMembers: { role: string; name: string }[];
onLink: (reqIds: string[], addedBy: string) => void;
onUnlink: (reqId: string) => void;
onCreateChange: (data: Partial<Requirement>) => void;
currentUserName: string;
readOnly?: boolean;
}
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName, readOnly = false }: Props) {
const [showAddModal, setShowAddModal] = useState(false);
const [showChangeModal, setShowChangeModal] = useState(false);
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
const availableReqs = getProjectAdoptedRequirementCandidates(requirements, projectId).filter((r) => !r.versionId);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} </span>
{!readOnly && (
<div className="flex items-center gap-2">
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<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>
)}
</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)]"></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>
{!readOnly && <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) => {
const devStatus = deriveReqDevStatus(req.id, devTasks);
const isDeveloping = !canEditRequirement(req.id, devTasks);
return (
<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-[180px]">
<span className="block truncate" title={req.description || ''}>{req.description || '-'}</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${req.reqType === 'change' ? 'bg-orange-50 text-orange-600' : 'bg-zinc-100 text-zinc-600'}`}>
{req.reqType === 'change' ? '变更需求' : '原始需求'}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{req.changeReason ? CHANGE_REASON_LABEL[req.changeReason] : '-'}
</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">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
{REQ_DEV_STATUS_LABEL[devStatus]}
</span>
</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '系统添加'}</td>
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.createdAt?.slice(0, 10) || '-'}</td>
{!readOnly && (
<td className="px-4 py-3 text-right">
{isDeveloping ? (
<span className="text-[11px] text-[var(--ink-muted)]"></span>
) : (
<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 && !readOnly && (
<AddRequirementModal
available={availableReqs}
onClose={() => setShowAddModal(false)}
onConfirm={(ids) => { onLink(ids, currentUserName); setShowAddModal(false); }}
/>
)}
{showChangeModal && !readOnly && (
<ChangeRequirementModal
versionMembers={versionMembers}
currentUserName={currentUserName}
onClose={() => setShowChangeModal(false)}
onSubmit={(data) => { onCreateChange(data); setShowChangeModal(false); }}
/>
)}
</div>
);
}
function ChangeRequirementModal({ versionMembers, currentUserName, onClose, onSubmit }: {
versionMembers: { role: string; name: string }[];
currentUserName: string;
onClose: () => void;
onSubmit: (data: Partial<Requirement>) => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [changeBy, setChangeBy] = useState(currentUserName);
const [changeReason, setChangeReason] = useState<ChangeReason | ''>('');
const handleSubmit = () => {
if (!title.trim() || !changeReason || !changeBy) return;
onSubmit({
title: title.trim(),
description: description.trim(),
changeBy,
changeReason: changeReason as ChangeReason,
reqType: 'change',
});
};
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)]"></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>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<FilterSelect
value={changeBy || 'all'}
onChange={(value) => setChangeBy(value === 'all' ? '' : value)}
options={versionMembers.map((member) => ({ value: member.name, label: member.name }))}
allLabel="选择变更人员"
className="w-full"
/>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<FilterSelect
value={changeReason || 'all'}
onChange={(value) => setChangeReason(value === 'all' ? '' : value as ChangeReason)}
options={Object.entries(CHANGE_REASON_LABEL).map(([key, label]) => ({ value: key, label }))}
allLabel="请选择变更原因"
className="w-full"
/>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> <span className="text-red-500">*</span></label>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="变更需求标题" 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>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} placeholder="变更需求详细描述" 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 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={handleSubmit} disabled={!title.trim() || !changeReason} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-orange-500 text-white hover:bg-orange-600 disabled:opacity-50"></button>
</div>
</div>
</div>
</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>
{filtered.length > 0 && (
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-[var(--ink-muted)]"> {filtered.length} {selected.size} </span>
<div className="flex gap-2">
<button type="button" onClick={() => setSelected(new Set(filtered.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline"></button>
<button type="button" onClick={() => setSelected(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline"></button>
</div>
</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>
);
}