feat: 实现产品/项目/版本三大模块完整功能
- 产品列表:拖拽排序持久化、编辑弹窗、删除校验(活跃版本需迁移) - 项目列表:新建项目表单(产品选择+重名校验)、产品筛选、版本归属修复 - 版本列表:新建版本表单(迭代类型自动生成版本号)、状态/项目筛选、倒序排列 - 项目详情页:概览统计、人员墙(参与次数)、版本时间线(阶段流水线+进度条) - 全局优化:筛选栏统一为header下方固定行、按钮颜色改为主题蓝、API探测300ms、store缓存 - 数据持久化到localStorage,支持离线开发 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,94 +1,530 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
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 { ProductCard } from '@/components/product/ProductCard';
|
||||
import { ProductForm } from '@/components/product/ProductForm';
|
||||
import { VersionChip } from '@/components/version/VersionChip';
|
||||
import type { VersionStatus } from '@/lib/version-status';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const router = useRouter();
|
||||
const { products, loading, fetchProducts, createProduct } = useProductStore();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
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 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
/* ─── 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 filtered = products.filter((p) =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleCreate = async (data: { name: string; description: string }) => {
|
||||
await createProduct(data);
|
||||
setShowForm(false);
|
||||
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 className="flex h-full flex-col">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-6">
|
||||
<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)]">
|
||||
{products.length}
|
||||
</span>
|
||||
</div>
|
||||
<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
|
||||
onClick={() => setShowForm(true)}
|
||||
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)] transition-colors hover:bg-[var(--accent-hover)]"
|
||||
{...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="拖拽排序"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
|
||||
新建产品
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-[1400px] p-6">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<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-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] 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>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-5 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 className="mb-4 text-[14px] font-semibold text-[var(--ink)]">新建产品</h2>
|
||||
<ProductForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-20 text-center text-[13px] text-[var(--ink-muted)]">加载中…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||
<p className="text-[14px] font-medium text-[var(--ink-soft)]">
|
||||
{search ? '没有找到匹配的产品' : '尚未创建任何产品'}
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12.5px] text-[var(--ink-muted)]">
|
||||
{search ? '尝试其他关键字' : '点击右上角"新建产品"开始'}
|
||||
</p>
|
||||
<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="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product as any}
|
||||
onClick={(id) => router.push(`/products/${id}`)}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user