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:
Script Generator
2026-06-09 18:16:18 +08:00
parent 24ba61f929
commit 9a0b16a8f1
36 changed files with 4355 additions and 272 deletions

View File

@@ -0,0 +1,489 @@
'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 { flattenProjects } 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 { 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 allProjects = useMemo(() => flattenProjects(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 [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]);
// 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);
}
// 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, 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="全部类型"
/>
</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="overflow-hidden 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">
<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 text-[12px] text-[var(--ink-soft)]">
{req.productOwner || '-'}
</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) && (
<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 }))}
sourceTargets={sourceTargets}
types={types}
platforms={platforms}
requirements={requirements}
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>
);
}