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

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ChevronLeft, Pencil, Trash2 } from 'lucide-react';
import { RequirementStatus } from '@ftb/shared';
import type { RequirementStatus } from '@/lib/requirement';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { RequirementTable } from '@/components/product/RequirementTable';
@@ -24,38 +24,37 @@ export default function ProductDetailPage() {
const { currentProduct, fetchProduct, updateProduct, deleteProduct } = useProductStore();
const {
requirements,
statusFilter,
fetchRequirements,
createRequirement,
updateRequirement,
updateStatus,
deleteRequirement,
setStatusFilter,
} = useRequirementStore();
const [showReqForm, setShowReqForm] = useState(false);
const [editingReq, setEditingReq] = useState<any>(null);
const [editingProduct, setEditingProduct] = useState(false);
const [activeTab, setActiveTab] = useState<'requirements' | 'projects' | 'versions'>('requirements');
const [reqStatusFilter, setReqStatusFilter] = useState<RequirementStatus | null>(null);
useEffect(() => {
fetchProduct(productId);
fetchRequirements(productId);
fetchRequirements();
}, [productId, fetchProduct, fetchRequirements]);
const productRequirements = useMemo(() => {
let list = requirements.filter((r) => r.productId === productId);
if (reqStatusFilter) list = list.filter((r) => r.status === reqStatusFilter);
return list;
}, [requirements, productId, reqStatusFilter]);
const handleFilterChange = (status: RequirementStatus | null) => {
setStatusFilter(status);
fetchRequirements(productId, status || undefined);
setReqStatusFilter(status);
};
const handleCreateReq = async (data: { title: string; description: string; priority: number }) => {
await createRequirement(productId, { ...data, creatorId: 'temp-user-id' });
setShowReqForm(false);
};
const handleUpdateReq = async (data: { title: string; description: string; priority: number }) => {
if (editingReq) {
await updateRequirement(productId, editingReq.id, data);
setEditingReq(null);
}
};
@@ -81,7 +80,7 @@ export default function ProductDetailPage() {
<div className="flex items-center gap-2">
<button
onClick={() => router.push('/products')}
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[12.5px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
className="flex h-7 items-center gap-1 rounded-md px-1.5 text-[12px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
@@ -94,14 +93,14 @@ export default function ProductDetailPage() {
<div className="flex gap-1.5">
<button
onClick={() => setEditingProduct(true)}
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12.5px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"
>
<Pencil className="h-3 w-3" strokeWidth={2} />
</button>
<button
onClick={handleDeleteProduct}
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12.5px] text-[var(--ink-soft)] hover:border-red-200 hover:bg-red-50 hover:text-red-600"
className="flex h-8 items-center gap-1 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2.5 text-[12px] text-[var(--ink-soft)] hover:border-red-200 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" strokeWidth={2} />
@@ -137,7 +136,7 @@ export default function ProductDetailPage() {
<div className="p-5">
{editingProduct && (
<div className="mb-5 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="mb-4 text-[14px] font-semibold text-[var(--ink)]"></h3>
<h3 className="mb-4 text-[13px] font-semibold text-[var(--ink)]"></h3>
<ProductForm
initialData={{ name: currentProduct.name, description: currentProduct.description }}
onSubmit={async (data) => {
@@ -154,7 +153,7 @@ export default function ProductDetailPage() {
<>
{(showReqForm || editingReq) && (
<div className="mb-5 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="mb-4 text-[14px] font-semibold text-[var(--ink)]">
<h3 className="mb-4 text-[13px] font-semibold text-[var(--ink)]">
{editingReq ? '编辑需求' : '新建需求'}
</h3>
<RequirementForm
@@ -168,12 +167,12 @@ export default function ProductDetailPage() {
</div>
)}
<RequirementTable
requirements={requirements as any}
statusFilter={statusFilter}
requirements={productRequirements as any}
statusFilter={reqStatusFilter}
onFilterChange={handleFilterChange}
onEdit={(req) => setEditingReq(req)}
onStatusChange={(id, status) => updateStatus(productId, id, status)}
onDelete={(id) => deleteRequirement(productId, id)}
onStatusChange={() => {}}
onDelete={(id) => deleteRequirement(id)}
onCreate={() => setShowReqForm(true)}
/>
</>

View File

@@ -209,7 +209,7 @@ function EditProductModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--line)]">
<h3 className="text-[14px] font-semibold"></h3>
<h3 className="text-[13px] font-semibold"></h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
@@ -257,7 +257,7 @@ function MigrateDialog({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--line)]">
<h3 className="text-[14px] font-semibold"></h3>
<h3 className="text-[13px] font-semibold"></h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] hover:text-[var(--ink)]"