- 产品列表:拖拽排序持久化、编辑弹窗、删除校验(活跃版本需迁移) - 项目列表:新建项目表单(产品选择+重名校验)、产品筛选、版本归属修复 - 版本列表:新建版本表单(迭代类型自动生成版本号)、状态/项目筛选、倒序排列 - 项目详情页:概览统计、人员墙(参与次数)、版本时间线(阶段流水线+进度条) - 全局优化:筛选栏统一为header下方固定行、按钮颜色改为主题蓝、API探测300ms、store缓存 - 数据持久化到localStorage,支持离线开发 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
531 lines
19 KiB
TypeScript
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-[14px] 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-[14px] 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>
|
|
);
|
|
}
|