Files
ftb-project-management/apps/web/app/products/page.tsx
Script Generator 9a0b16a8f1 feat: 实现需求管理、加班记录、成员/角色管理模块
- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择
- 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出
- 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置
- 角色管理:卡片列表、系统角色保护、CRUD
- 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页
- 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致

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

531 lines
19 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo } from 'react';
import {
Plus,
Search,
GripVertical,
ChevronRight,
Pencil,
Trash2,
X,
AlertTriangle,
Package,
} from 'lucide-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useProductStore } from '@/stores/useProductStore';
import { ProductForm } from '@/components/product/ProductForm';
import { VersionChip } from '@/components/version/VersionChip';
import type { VersionStatus } from '@/lib/version-status';
type ProductOverview = {
id: string;
name: string;
description: string;
projects: { id: string; name: string; description: string; createdAt: string }[];
versions: {
id: string;
name: string;
status?: string;
releaseDate: string | null;
createdAt: string;
}[];
_count?: { requirements: number; projects: number; versions?: number };
};
/* ─── Sortable Product Row ─── */
function SortableProductRow({
product,
expanded,
onToggle,
onEdit,
onDelete,
}: {
product: ProductOverview;
expanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: product.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const activeVersions = product.versions.filter(
(v) => v.status === 'developing' || v.status === 'planned'
);
const releasedCount = product.versions.filter((v) => v.status === 'released').length;
return (
<div
ref={setNodeRef}
style={style}
className="border-b border-[var(--line-soft)] last:border-b-0"
>
<div className="flex items-center gap-2 px-4 py-3 group">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="拖拽排序"
>
<GripVertical className="w-4 h-4" />
</button>
<div
className="flex-1 flex items-center gap-2 cursor-pointer min-w-0"
onClick={onToggle}
>
<ChevronRight
className={`w-4 h-4 text-[var(--ink-muted)] transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
<span className="text-[13px] font-medium truncate">{product.name}</span>
{product.description && (
<span className="text-[12px] text-[var(--ink-muted)] truncate hidden sm:inline">
{product.description}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={onEdit}
className="p-1.5 rounded-md hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] hover:text-[var(--ink)] opacity-0 group-hover:opacity-100 transition-all"
aria-label="编辑"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-600 opacity-0 group-hover:opacity-100 transition-all"
aria-label="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-3 text-[11px] text-[var(--ink-muted)] ml-2 pl-2 border-l border-[var(--line-soft)]">
<span className="hidden md:inline">{product._count?.projects ?? product.projects.length} </span>
<span className="hidden md:inline">{product._count?.versions ?? product.versions.length} </span>
{product._count?.requirements != null && (
<span className="hidden md:inline">{product._count.requirements} </span>
)}
</div>
</div>
{expanded && (
<div className="bg-[var(--bg-subtle)] border-t border-[var(--line-soft)]">
{product.projects.length === 0 ? (
<div className="px-12 py-6 text-[12px] text-[var(--ink-muted)] text-center">
</div>
) : (
<div className="px-12 py-2">
{product.projects.map((proj) => {
const projVersions = product.versions.filter((v) =>
v.name.toLowerCase().startsWith(proj.name.toLowerCase())
);
const projActive = projVersions.filter(
(v) => v.status === 'developing' || v.status === 'planned'
);
const projReleased = projVersions.filter((v) => v.status === 'released').length;
return (
<div
key={proj.id}
className="flex items-center gap-3 py-2 border-b border-[var(--line-soft)] last:border-b-0"
>
<span className="text-[12px] font-medium text-[var(--ink-soft)] min-w-0 flex-shrink-0">
{proj.name}
</span>
{proj.description && (
<span className="text-[11px] text-[var(--ink-muted)] truncate hidden lg:inline">
{proj.description}
</span>
)}
<div className="flex-1 flex items-center gap-1.5 flex-wrap justify-end">
{projActive.map((v) => (
<VersionChip
key={v.id}
name={v.name}
status={(v.status as VersionStatus) ?? 'developing'}
/>
))}
{projReleased > 0 && (
<span className="text-[11px] text-[var(--ink-muted)] px-1.5">
{projReleased}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}
/* ─── Edit Product Modal ─── */
function EditProductModal({
product,
onClose,
onSubmit,
}: {
product: ProductOverview;
onClose: () => void;
onSubmit: (data: { name: string; description: string }) => void | Promise<void>;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--line)]">
<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)]"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5">
<ProductForm
initialData={{ name: product.name, description: product.description }}
onSubmit={onSubmit}
onCancel={onClose}
/>
</div>
</div>
</div>
);
}
/* ─── Migrate Dialog ─── */
function MigrateDialog({
source,
others,
onClose,
onConfirm,
}: {
source: ProductOverview;
others: ProductOverview[];
onClose: () => void;
onConfirm: (targetId: string) => void | Promise<void>;
}) {
const [targetId, setTargetId] = useState('');
const activeVersions = source.versions.filter(
(v) => v.status === 'developing' || v.status === 'planned'
);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] shadow-[var(--shadow-md)] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[var(--line)]">
<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)]"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
<div className="flex items-start gap-3 p-3 rounded-xl bg-amber-50 border border-amber-200">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-[13px] text-amber-800">
<p className="font-medium mb-1">
{activeVersions.length}
</p>
<ul className="space-y-0.5 text-[12px]">
{activeVersions.map((v) => (
<li key={v.id}>· {v.name}</li>
))}
</ul>
</div>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1.5 font-medium">
</label>
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full h-9 px-3 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] text-[13px] focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-ring)]"
>
<option value="">...</option>
{others.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={onClose}
className="h-8 px-3 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] hover:bg-[var(--bg-hover)] text-[13px] text-[var(--ink-soft)] transition-colors"
>
</button>
<button
onClick={() => targetId && onConfirm(targetId)}
disabled={!targetId || others.length === 0}
className="h-8 px-3 rounded-lg bg-red-600 hover:bg-red-700 disabled:bg-zinc-300 disabled:cursor-not-allowed text-white text-[13px] font-medium transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
}
/* ─── Main Page ─── */
export default function ProductsPage() {
const {
overview,
loading,
fetchOverview,
createProduct,
updateProduct,
deleteProduct,
reorderProducts,
migrateAndDeleteProduct,
} = useProductStore();
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<ProductOverview | null>(null);
const [migrateTarget, setMigrateTarget] = useState<ProductOverview | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
fetchOverview();
}, [fetchOverview]);
useEffect(() => {
if (!hasInitialized && overview.length > 0) {
setExpanded(new Set([overview[0].id]));
setHasInitialized(true);
}
}, [overview, hasInitialized]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const filtered = useMemo(() => {
if (!search.trim()) return overview;
const q = search.toLowerCase();
return overview.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
(p.description ?? '').toLowerCase().includes(q)
);
}, [overview, search]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = overview.findIndex((p) => p.id === active.id);
const newIndex = overview.findIndex((p) => p.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const next = [...overview];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
reorderProducts(next.map((p) => p.id));
};
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
};
const handleDelete = (product: ProductOverview) => {
const active = product.versions.filter(
(v) => v.status === 'developing' || v.status === 'planned'
);
if (active.length === 0) {
if (confirm(`确认删除产品「${product.name}」?此操作不可撤销。`)) {
deleteProduct(product.id);
}
} else {
setMigrateTarget(product);
}
};
return (
<div className="flex h-full flex-col bg-[var(--bg)] text-[var(--ink)]">
{/* Header */}
<div className="h-14 shrink-0 flex items-center justify-between px-5 border-b border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight"></h1>
<span className="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-soft)] text-[11px] font-medium">
{overview.length}
</span>
</div>
<button
onClick={() => setShowCreate((v) => !v)}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white text-[13px] font-medium shadow-[var(--shadow-sm)] transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Filters */}
<div className="flex shrink-0 items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--ink-muted)]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索产品..."
className="w-full h-8 pl-9 pr-3 rounded-lg border border-[var(--line)] bg-[var(--bg)] text-[13px] placeholder:text-[var(--ink-muted)] focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-ring)] transition-colors"
/>
</div>
<div className="ml-auto flex items-center gap-3 text-[12px] text-[var(--ink-muted)]">
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#2563eb]" />
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-orange-500" />
</span>
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-400" />
</span>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
{/* Inline Create Form */}
{showCreate && (
<div className="mb-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[13px] font-semibold"></h3>
<button
onClick={() => setShowCreate(false)}
className="p-1 rounded-md hover:bg-[var(--bg-hover)] text-[var(--ink-muted)] hover:text-[var(--ink)]"
>
<X className="w-4 h-4" />
</button>
</div>
<ProductForm
onSubmit={async (data) => {
await createProduct(data);
setShowCreate(false);
}}
onCancel={() => setShowCreate(false)}
/>
</div>
)}
{/* Product List */}
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden shadow-[var(--shadow-sm)]">
{loading && overview.length === 0 ? (
<div className="py-16 text-center text-[13px] text-[var(--ink-muted)]">...</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center text-[13px] text-[var(--ink-muted)]">
<Package className="w-10 h-10 mx-auto mb-2 opacity-40" />
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filtered.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
{filtered.map((product) => (
<SortableProductRow
key={product.id}
product={product}
expanded={expanded.has(product.id)}
onToggle={() => toggleExpand(product.id)}
onEdit={() => setEditing(product)}
onDelete={() => handleDelete(product)}
/>
))}
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* Edit Modal */}
{editing && (
<EditProductModal
product={editing}
onClose={() => setEditing(null)}
onSubmit={async (data) => {
await updateProduct(editing.id, data);
setEditing(null);
}}
/>
)}
{/* Migrate Dialog */}
{migrateTarget && (
<MigrateDialog
source={migrateTarget}
others={overview.filter((p) => p.id !== migrateTarget.id)}
onClose={() => setMigrateTarget(null)}
onConfirm={async (targetId) => {
await migrateAndDeleteProduct(migrateTarget.id, targetId);
setMigrateTarget(null);
}}
/>
)}
</div>
);
}