Files
ftb-project-management/apps/web/app/requirements/page.tsx
Script Generator 57e55b6788 fix: 修复录入人员不显示 + 状态列改名区分
1. 录入人员字段(creator):
   - RequirementModal 新增 currentUserName prop
   - handleSubmit 自动填入 creator
   - 编辑时保留原有 creator,新建时取当前登录用户
   - 修复"新建需求后录入人员为空"的 bug

2. 表格状态列改名(消除混淆):
   - "状态" → "业务状态"(req.status,业务流转)
   - "开发状态" → "实际进度"(从 DevTask 派生)
   - 两个含义不同的状态不再叫同一个名字

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 19:26:16 +08:00

520 lines
24 KiB
TypeScript
Raw 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 { useEffect, useMemo, useState } from 'react';
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useProductStore } from '@/stores/useProductStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenProjects, flattenVersions } from '@/lib/derive';
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
import { Pagination, usePagination } from '@/components/Pagination';
import { RequirementModal } from '@/components/requirement/RequirementModal';
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
import { FilterSelect } from '@/components/FilterSelect';
const PRIORITY_COLORS: 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 STATUS_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'pending_review', label: '待评审' },
{ key: 'adopted', label: '已采纳' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'planned', label: '已规划' },
{ key: 'developing', label: '开发中' },
{ key: 'testing', label: '测试中' },
{ key: 'released', label: '已上线' },
{ key: 'closed', label: '已关闭' },
];
export default function RequirementsPage() {
const {
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
sourceTargets, types, platforms,
addSourceTarget, updateSourceTarget, deleteSourceTarget,
addType, updateType, deleteType,
addPlatform, updatePlatform, deletePlatform,
} = useRequirementStore();
const { overview, fetchOverview } = useProductStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '系统';
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [versionFilter, setVersionFilter] = useState('all');
const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [dateSort, setDateSort] = useState<'desc' | 'asc'>('desc');
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
// Resolve version name from overview
const resolveVersionName = (versionId?: string): string => {
if (!versionId) return '-';
for (const product of overview) {
const version = product.versions?.find((v: any) => v.id === versionId);
if (version) return version.name;
}
return '-';
};
const filtered = useMemo(() => {
let list = [...requirements];
// search filter
if (search) {
const q = search.toLowerCase();
list = list.filter(
(r) => r.code.toLowerCase().includes(q) || r.title.toLowerCase().includes(q),
);
}
// status filter
if (statusFilter !== 'all') {
list = list.filter((r) => r.status === statusFilter);
}
// project filter
if (projectFilter !== 'all') {
list = list.filter((r) => r.projectId === projectFilter);
}
// priority filter
if (priorityFilter !== 'all') {
list = list.filter((r) => r.priority === priorityFilter);
}
// type filter
if (typeFilter !== 'all') {
list = list.filter((r) => r.typeId === typeFilter);
}
if (versionFilter !== 'all') {
list = list.filter((r) => r.versionId === versionFilter);
}
// sort by createdAt
list.sort((a, b) => {
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return dateSort === 'asc' ? diff : -diff;
});
return list;
}, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, versionFilter, dateSort]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
const handleEdit = (req: Requirement) => {
setViewingReq(null);
setEditingReq(req);
setShowModal(true);
};
const handleRowClick = (req: Requirement) => {
setViewingReq(req);
};
const handleCreate = () => {
setEditingReq(null);
setShowModal(true);
};
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 gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
{requirements.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setDrawerType('source')}
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
>
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
</button>
<button
onClick={handleCreate}
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>
</header>
{/* Filters */}
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索编号或标题"
className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
</div>
{/* Status tabs */}
<div className="flex flex-wrap gap-1">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${
statusFilter === tab.key
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Project dropdown */}
<FilterSelect
value={projectFilter}
onChange={setProjectFilter}
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
placeholder="全部项目"
allLabel="全部项目"
/>
{/* Priority dropdown */}
<FilterSelect
value={priorityFilter}
onChange={setPriorityFilter}
options={['P0', 'P1', 'P2', 'P3', 'P4'].map((p) => ({ value: p, label: p }))}
placeholder="全部优先级"
allLabel="全部优先级"
/>
{/* Type dropdown */}
<FilterSelect
value={typeFilter}
onChange={setTypeFilter}
options={types.map((t) => ({ value: t.id, label: t.name }))}
placeholder="全部类型"
allLabel="全部类型"
/>
{/* Version dropdown */}
<FilterSelect
value={versionFilter}
onChange={setVersionFilter}
options={allVersions.map((v) => ({ value: v.id, label: v.name }))}
placeholder="全部版本"
allLabel="全部版本"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
<div className="px-5 py-4">
{filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
<p className="text-[13px] font-medium text-[var(--ink-soft)]"></p>
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]"></p>
</div>
) : (
<>
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<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)] bg-[var(--bg-subtle)]">
<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>
<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)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
onClick={() => setDateSort(dateSort === 'desc' ? 'asc' : 'desc')}
>
<span className="inline-flex items-center gap-1">
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
</span>
</th>
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
</tr>
</thead>
<tbody>
{paged.map((req) => (
<tr
key={req.id}
onClick={() => handleRowClick(req)}
className="border-b border-[var(--line-soft)] last:border-0 cursor-pointer transition-colors hover:bg-[var(--bg-subtle)]"
>
{/* 需求编号 */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="font-medium text-[var(--ink)]">{req.code}</span>
</td>
{/* 需求概述 */}
<td className="px-4 py-3">
<span className="block max-w-[200px] truncate text-[var(--ink)]">{req.title}</span>
</td>
{/* 需求来源 */}
<td className="px-4 py-3 text-[13px] text-[var(--ink-soft)]">
{SOURCE_TYPE_LABEL[req.sourceType]}: {req.sourceTarget}
</td>
{/* 所属项目 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{allProjects.find((p) => p.id === req.projectId)?.name ?? '-'}
</td>
{/* 需求类型 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{types.find((t) => t.id === req.typeId)?.name ?? '-'}
</td>
{/* 状态 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${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-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? 'bg-zinc-100 text-zinc-500'}`}>
{req.priority}
</span>
</td>
{/* 所属版本 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{resolveVersionName(req.versionId)}
</td>
{/* 开发状态 */}
<td className="px-4 py-3">
{(() => {
const devStatus = deriveReqDevStatus(req.id, devTasks);
return (
<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.creator}
</td>
{/* 录入日期 */}
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
{new Date(req.createdAt).toISOString().slice(0, 10)}
</td>
{/* 操作 */}
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasks) && (
<button
onClick={() => handleEdit(req)}
className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
>
</button>
)}
{/* 待评审:采纳/拒绝 */}
{req.status === 'pending_review' && (
<>
<button
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
className="h-6 px-2 rounded text-[11px] font-medium text-emerald-600 hover:bg-emerald-50 transition-colors"
>
</button>
<button
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
className="h-6 px-2 rounded text-[11px] font-medium text-red-600 hover:bg-red-50 transition-colors"
>
</button>
</>
)}
{/* 已采纳/已规划:关闭 */}
{(req.status === 'adopted' || req.status === 'planned') && (
<button
onClick={() => updateRequirement(req.id, { status: 'closed' })}
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
>
</button>
)}
{/* 删除:待评审/已采纳/已拒绝/已关闭 */}
{(['pending_review', 'adopted', 'rejected', 'closed'] as const).includes(req.status as any) && (
<button
onClick={() => deleteRequirement(req.id)}
className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"
>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
</>
)}
</div>
</div>
{/* Detail drawer */}
{viewingReq && (
<RequirementDetail
requirement={viewingReq}
projects={allProjects.map((p) => ({ id: p.id, name: p.name }))}
types={types}
platforms={platforms}
sourceTargets={sourceTargets}
resolveVersionName={resolveVersionName}
onClose={() => setViewingReq(null)}
onEdit={() => handleEdit(viewingReq)}
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
onCloseReq={() => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); }}
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
/>
)}
{/* Modal */}
{showModal && (
<RequirementModal
open={true}
initial={editingReq}
products={overview.map((p) => ({ id: p.id, name: p.name }))}
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
versions={allVersions.map((v) => ({ id: v.id, name: v.name, projectId: v.projectId }))}
sourceTargets={sourceTargets}
types={types}
platforms={platforms}
requirements={requirements}
currentUserName={currentUserName}
onClose={() => { setShowModal(false); setEditingReq(null); }}
onSubmit={(data) => {
if (editingReq) {
updateRequirement(editingReq.id, data);
} else {
createRequirement(data);
}
setShowModal(false);
setEditingReq(null);
}}
onOpenDrawer={(type) => setDrawerType(type)}
/>
)}
{/* Drawer */}
{drawerType === 'source' && (
<SourceDrawer
open={true}
items={sourceTargets}
onClose={() => setDrawerType(null)}
onAdd={addSourceTarget}
onUpdate={updateSourceTarget}
onDelete={deleteSourceTarget}
/>
)}
{drawerType === 'type' && (
<DictDrawer
open={true}
title="需求类型管理"
items={types}
onClose={() => setDrawerType(null)}
onAdd={addType}
onUpdate={updateType}
onDelete={deleteType}
/>
)}
{drawerType === 'platform' && (
<DictDrawer
open={true}
title="支持端管理"
items={platforms}
onClose={() => setDrawerType(null)}
onAdd={addPlatform}
onUpdate={updatePlatform}
onDelete={deletePlatform}
/>
)}
{/* 拒绝原因弹窗 */}
{rejectingReq && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setRejectingReq(null)}>
<div className="w-full max-w-sm rounded-xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-[13px] font-semibold text-[var(--ink)] mb-3"></h3>
<p className="text-[12px] text-[var(--ink-muted)] mb-2">{rejectingReq.title}</p>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请填写拒绝原因..."
rows={3}
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none resize-none"
/>
<div className="flex justify-end gap-2 mt-3">
<button onClick={() => setRejectingReq(null)} 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={() => {
if (!rejectReason.trim()) return;
updateRequirement(rejectingReq.id, { status: 'rejected', description: rejectingReq.description + `\n\n【拒绝原因】${rejectReason.trim()}` });
setRejectingReq(null);
}}
disabled={!rejectReason.trim()}
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}