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,164 @@
'use client';
import { useState } from 'react';
import { X, Plus, Pencil, Trash2, Check } from 'lucide-react';
import type { DictItem, SourceTarget, SourceType } from '@/lib/requirement';
import { SOURCE_TYPE_LABEL } from '@/lib/requirement';
interface DictDrawerProps {
open: boolean;
title: string;
items: DictItem[];
onClose: () => void;
onAdd: (name: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
interface SourceDrawerProps {
open: boolean;
items: SourceTarget[];
onClose: () => void;
onAdd: (name: string, sourceType: SourceType) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
}
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
export function DictDrawer({ open, title, items, onClose, onAdd, onUpdate, onDelete }: DictDrawerProps) {
if (!open) return null;
return (
<DrawerShell title={title} onClose={onClose}>
<ItemList items={items} onAdd={onAdd} onUpdate={onUpdate} onDelete={onDelete} />
</DrawerShell>
);
}
export function SourceDrawer({ open, items, onClose, onAdd, onUpdate, onDelete }: SourceDrawerProps) {
const [activeTab, setActiveTab] = useState<SourceType>('customer');
if (!open) return null;
const filtered = items.filter((t) => t.sourceType === activeTab);
return (
<DrawerShell title="来源对象管理" onClose={onClose}>
{/* Tabs */}
<div className="flex flex-wrap gap-1 px-4 py-2 border-b border-[var(--line)]">
{SOURCE_TYPES.map((type) => (
<button
key={type}
onClick={() => setActiveTab(type)}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition-colors ${activeTab === type ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] text-[var(--ink-soft)] hover:text-[var(--ink)]'}`}
>
{SOURCE_TYPE_LABEL[type]}
</button>
))}
</div>
<ItemList
items={filtered}
onAdd={(name) => onAdd(name, activeTab)}
onUpdate={onUpdate}
onDelete={onDelete}
placeholder={`添加${SOURCE_TYPE_LABEL[activeTab]}来源对象`}
/>
</DrawerShell>
);
}
function DrawerShell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
<div className="w-80 h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--line)]">
<span className="text-[13px] font-semibold text-[var(--ink)]">{title}</span>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
<X className="h-4 w-4" />
</button>
</div>
{children}
</div>
</div>
);
}
function ItemList({ items, onAdd, onUpdate, onDelete, placeholder }: {
items: { id: string; name: string }[];
onAdd: (name: string) => void;
onUpdate: (id: string, name: string) => void;
onDelete: (id: string) => void;
placeholder?: string;
}) {
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const handleAdd = () => {
const trimmed = newName.trim();
if (!trimmed) return;
onAdd(trimmed);
setNewName('');
};
const confirmEdit = () => {
if (editingId && editingName.trim()) {
onUpdate(editingId, editingName.trim());
}
setEditingId(null);
setEditingName('');
};
return (
<>
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--line)]">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder={placeholder || '输入名称...'}
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
<button onClick={handleAdd} className="h-8 w-8 flex items-center justify-center rounded-lg bg-[var(--accent)] text-white hover:opacity-90">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{items.map((item) => (
<div key={item.id} className="group flex items-center px-4 py-2 hover:bg-[var(--bg-subtle)]">
{editingId === item.id ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditingId(null); }}
autoFocus
className="flex-1 h-7 rounded-lg border border-[var(--accent)] bg-[var(--bg-card)] px-2 text-[13px] text-[var(--ink)] outline-none"
/>
<button onClick={confirmEdit} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--accent)]">
<Check className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<span className="flex-1 text-[13px] text-[var(--ink)]">{item.name}</span>
<div className="hidden group-hover:flex items-center gap-1">
<button onClick={() => { setEditingId(item.id); setEditingName(item.name); }} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)]">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => onDelete(item.id)} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)] hover:text-red-500">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
))}
{items.length === 0 && (
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
</>
);
}