From eca65f1bceca76e085593ab3d1e7d88b23771bae Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 8 Jun 2026 17:11:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BA=A7=E5=93=81/?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE/=E7=89=88=E6=9C=AC=E4=B8=89=E5=A4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=AE=8C=E6=95=B4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 产品列表:拖拽排序持久化、编辑弹窗、删除校验(活跃版本需迁移) - 项目列表:新建项目表单(产品选择+重名校验)、产品筛选、版本归属修复 - 版本列表:新建版本表单(迭代类型自动生成版本号)、状态/项目筛选、倒序排列 - 项目详情页:概览统计、人员墙(参与次数)、版本时间线(阶段流水线+进度条) - 全局优化:筛选栏统一为header下方固定行、按钮颜色改为主题蓝、API探测300ms、store缓存 - 数据持久化到localStorage,支持离线开发 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/modules/product/product.controller.ts | 5 + .../src/modules/product/product.service.ts | 17 + apps/web/app/globals.css | 6 +- apps/web/app/products/page.tsx | 574 +++++++++++++++--- apps/web/app/projects/[id]/page.tsx | 362 +++++++++++ apps/web/app/projects/page.tsx | 386 ++++++++++++ apps/web/app/versions/page.tsx | 359 +++++++++++ apps/web/components/product/ProductForm.tsx | 22 +- .../components/product/RequirementForm.tsx | 2 +- .../components/product/RequirementTable.tsx | 2 +- apps/web/components/version/VersionChip.tsx | 17 + apps/web/lib/api.ts | 23 +- apps/web/lib/derive.ts | 98 +++ apps/web/lib/stage.ts | 36 ++ apps/web/lib/version-status.ts | 19 + apps/web/package.json | 3 + apps/web/stores/useProductStore.ts | 266 +++++++- pnpm-lock.yaml | 68 +++ 18 files changed, 2171 insertions(+), 94 deletions(-) create mode 100644 apps/web/app/projects/[id]/page.tsx create mode 100644 apps/web/app/projects/page.tsx create mode 100644 apps/web/app/versions/page.tsx create mode 100644 apps/web/components/version/VersionChip.tsx create mode 100644 apps/web/lib/derive.ts create mode 100644 apps/web/lib/stage.ts create mode 100644 apps/web/lib/version-status.ts diff --git a/apps/server/src/modules/product/product.controller.ts b/apps/server/src/modules/product/product.controller.ts index 0221da2..f4cfe06 100644 --- a/apps/server/src/modules/product/product.controller.ts +++ b/apps/server/src/modules/product/product.controller.ts @@ -17,6 +17,11 @@ export class ProductController { return this.productService.findAll(); } + @Get('overview') + findAllWithChildren() { + return this.productService.findAllWithChildren(); + } + @Get(':id') findOne(@Param('id') id: string) { return this.productService.findOne(id); diff --git a/apps/server/src/modules/product/product.service.ts b/apps/server/src/modules/product/product.service.ts index dd4679e..1a462d6 100644 --- a/apps/server/src/modules/product/product.service.ts +++ b/apps/server/src/modules/product/product.service.ts @@ -20,6 +20,23 @@ export class ProductService { }); } + findAllWithChildren() { + return this.prisma.product.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + projects: { + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, description: true, createdAt: true }, + }, + versions: { + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, releaseDate: true, createdAt: true }, + }, + _count: { select: { requirements: true, projects: true, versions: true } }, + }, + }); + } + async findOne(id: string) { const product = await this.prisma.product.findUnique({ where: { id }, diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index c848569..22b04f4 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -21,10 +21,10 @@ --line-soft: #f4f4f5; /* Brand — calm blue */ - --accent: #3b82f6; - --accent-hover: #2563eb; + --accent: #2563eb; + --accent-hover: #1d4ed8; --accent-soft: #eff6ff; - --accent-ring: rgba(59, 130, 246, 0.15); + --accent-ring: rgba(37, 99, 235, 0.18); /* Shadow */ --shadow-sm: 0 1px 2px rgba(24, 24, 27, 0.04); diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index 03baf31..2936d74 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -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 ( -
-
-
-

产品

- - {products.length} - -
+
+
-
-
-
-
-
- - 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)]" - /> -
-
- - {showForm && ( -
-

新建产品

- setShowForm(false)} /> -
+
+ + {product.name} + {product.description && ( + + {product.description} + )} +
- {loading ? ( -
加载中…
- ) : filtered.length === 0 ? ( -
-

- {search ? '没有找到匹配的产品' : '尚未创建任何产品'} -

-

- {search ? '尝试其他关键字' : '点击右上角"新建产品"开始'} -

+
+ + +
+ +
+ {product._count?.projects ?? product.projects.length} 项目 + {product._count?.versions ?? product.versions.length} 版本 + {product._count?.requirements != null && ( + {product._count.requirements} 需求 + )} +
+
+ + {expanded && ( +
+ {product.projects.length === 0 ? ( +
+ 暂无项目
) : ( -
- {filtered.map((product) => ( - router.push(`/products/${id}`)} - /> - ))} +
+ {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 ( +
+ + {proj.name} + + {proj.description && ( + + {proj.description} + + )} +
+ {projActive.map((v) => ( + + ))} + {projReleased > 0 && ( + + 已发布 {projReleased} + + )} +
+
+ ); + })}
)}
+ )} +
+ ); +} + +/* ─── Edit Product Modal ─── */ +function EditProductModal({ + product, + onClose, + onSubmit, +}: { + product: ProductOverview; + onClose: () => void; + onSubmit: (data: { name: string; description: string }) => void | Promise; +}) { + return ( +
+
e.stopPropagation()} + > +
+

编辑产品

+ +
+
+ +
); } + +/* ─── Migrate Dialog ─── */ +function MigrateDialog({ + source, + others, + onClose, + onConfirm, +}: { + source: ProductOverview; + others: ProductOverview[]; + onClose: () => void; + onConfirm: (targetId: string) => void | Promise; +}) { + const [targetId, setTargetId] = useState(''); + const activeVersions = source.versions.filter( + (v) => v.status === 'developing' || v.status === 'planned' + ); + + return ( +
+
e.stopPropagation()} + > +
+

删除产品

+ +
+
+
+ +
+

+ 该产品下还有 {activeVersions.length} 个未发布版本 +

+
    + {activeVersions.map((v) => ( +
  • · {v.name}
  • + ))} +
+
+
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +} + +/* ─── 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>(new Set()); + const [editing, setEditing] = useState(null); + const [migrateTarget, setMigrateTarget] = useState(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 ( +
+ {/* Header */} +
+
+

产品

+ + {overview.length} + +
+ +
+ + {/* Filters */} +
+
+ + 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" + /> +
+
+ + 开发中 + + + 规划中 + + + 已发布 + +
+
+ + {/* Content */} +
+ + {/* Inline Create Form */} + {showCreate && ( +
+
+

新建产品

+ +
+ { + await createProduct(data); + setShowCreate(false); + }} + onCancel={() => setShowCreate(false)} + /> +
+ )} + + {/* Product List */} +
+ {loading && overview.length === 0 ? ( +
加载中...
+ ) : filtered.length === 0 ? ( +
+ + 暂无产品 +
+ ) : ( + + p.id)} + strategy={verticalListSortingStrategy} + > + {filtered.map((product) => ( + toggleExpand(product.id)} + onEdit={() => setEditing(product)} + onDelete={() => handleDelete(product)} + /> + ))} + + + )} +
+
+ + {/* Edit Modal */} + {editing && ( + setEditing(null)} + onSubmit={async (data) => { + await updateProduct(editing.id, data); + setEditing(null); + }} + /> + )} + + {/* Migrate Dialog */} + {migrateTarget && ( + p.id !== migrateTarget.id)} + onClose={() => setMigrateTarget(null)} + onConfirm={async (targetId) => { + await migrateAndDeleteProduct(migrateTarget.id, targetId); + setMigrateTarget(null); + }} + /> + )} +
+ ); +} diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx new file mode 100644 index 0000000..1fe36b2 --- /dev/null +++ b/apps/web/app/projects/[id]/page.tsx @@ -0,0 +1,362 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { ChevronLeft, Package, Calendar, Clock, Users, Tag } from 'lucide-react'; +import { useProductStore } from '@/stores/useProductStore'; +import { getProjectDetail } from '@/lib/derive'; +import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage'; +import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; + +interface VersionWithContext { + id: string; name: string; status: VersionStatus; + releaseDate: string | null; createdAt: string; + productId: string; productName: string; projectName: string; + currentStage?: Stage; + startDate?: string | null; + expectedReleaseDate?: string | null; + members?: { role: Role; name: string }[]; + progress?: { role: Role; percent: number; daysSpent: number }[]; +} + +/* ─── StatCard ─── */ +function StatCard({ value, label }: { value: number | string; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +/* ─── ProgressBar ─── */ +function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) { + return ( +
+ {ROLE_LABEL[role]} +
+
+
+ {percent}% + + {daysSpent === 0 ? '-' : `${daysSpent}天`} + +
+ ); +} + +/* ─── StagePipeline ─── */ +function StagePipeline({ currentStage }: { currentStage?: Stage }) { + const currentIdx = currentStage !== undefined ? STAGE_INDEX[currentStage] : -1; + + const stageLabel: Record = { + requirement: '需求', + product_design: '产品设计', + ui_design: 'UI设计', + dev: '开发', + integration: '联调', + testing: '测试', + released: '已发布', + }; + + return ( +
+ {STAGES.map((stage, idx) => { + const isCompleted = idx < currentIdx; + const isCurrent = idx === currentIdx; + const isFuture = idx > currentIdx; + + return ( +
+
+ {isCurrent ? ( +
+
+
+
+ ) : isCompleted ? ( +
+ ) : ( +
+ )} + + {stage.label} + +
+ {idx < STAGES.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +/* ─── VersionCard ─── */ +function VersionCard({ version }: { version: VersionWithContext }) { + const statusBg = VERSION_STATUS_BG[version.status]; + const statusLabel = VERSION_STATUS_LABEL[version.status]; + + // Mode: planned + if (version.status === 'planned') { + return ( +
+
+ {version.name} + + {statusLabel} + + 暂无详情 +
+
+ ); + } + + const totalDays = (version.progress ?? []).reduce((sum, p) => sum + p.daysSpent, 0); + + // Mode: released + if (version.status === 'released') { + return ( +
+
+ {version.name} + + {statusLabel} + +
+
+ + + {version.startDate ?? '-'} → {version.releaseDate ?? '-'} 发布 总 {totalDays} 天 + +
+ {version.members && version.members.length > 0 && ( +
+ {version.members + .map((m) => `${ROLE_LABEL[m.role]} ${m.name}`) + .join(' · ')} +
+ )} +
+ ); + } + + // Mode: developing + return ( +
+
+ {version.name} + + {statusLabel} + +
+ +
+ +
+ +
+
+ + + {version.startDate ?? '-'} 开始 → 预计 {version.expectedReleaseDate ?? '-'} + +
+
+ + 已耗时 {totalDays} 天 +
+
+ + {version.members && version.members.length > 0 && ( +
+ {version.members + .map((m) => `${ROLE_LABEL[m.role]} ${m.name}`) + .join(' · ')} +
+ )} + + {version.progress && version.progress.length > 0 && ( +
+ {version.progress.map((p) => ( + + ))} +
+ )} +
+ ); +} + +/* ─── Main Page Component ─── */ +export default function ProjectDetailPage() { + const params = useParams(); + const router = useRouter(); + const projectId = params.id as string; + const { overview, fetchOverview } = useProductStore(); + + useEffect(() => { + fetchOverview(); + }, [fetchOverview]); + + const project = useMemo( + () => getProjectDetail(overview, projectId), + [overview, projectId] + ); + + const sortedVersions = useMemo(() => { + if (!project) return []; + return [...project.versions].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, [project]); + + const stats = useMemo(() => { + if (!project) return { total: 0, developing: 0, released: 0, totalDays: 0 }; + const total = project.versions.length; + const developing = project.versions.filter((v) => v.status === 'developing').length; + const released = project.versions.filter((v) => v.status === 'released').length; + const totalDays = project.versions.reduce((sum, v) => { + return sum + (v.progress ?? []).reduce((s, p) => s + p.daysSpent, 0); + }, 0); + return { total, developing, released, totalDays }; + }, [project]); + + // Team members aggregation + const teamByRole = useMemo(() => { + if (!project) return {}; + const map: Record> = {} as any; + ROLES.forEach((r) => (map[r.key] = {})); + project.versions.forEach((v) => { + (v.members ?? []).forEach((m) => { + if (!map[m.role]) map[m.role] = {}; + map[m.role][m.name] = (map[m.role][m.name] || 0) + 1; + }); + }); + return map; + }, [project]); + + if (!project) { + return ( +
+

项目不存在

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + / + + {project.name} + +
+
+ + {project.productName} +
+
+ + {/* Content */} +
+
+ {/* Overview Stats Row */} +
+ + + + +
+ + {/* Team Members Section */} +
+
+ +

项目人员

+
+
+ {ROLES.map((role) => { + const peopleMap = (teamByRole as any)[role.key] || {}; + const people = Object.entries(peopleMap).sort((a, b) => (b[1] as number) - (a[1] as number)); + return ( +
+ + {role.label} + +
+ {people.length === 0 ? ( + - + ) : ( + people.map(([name, count]) => ( + + {name}({count as number}次) + + )) + )} +
+
+ ); + })} +
+
+ + {/* Version Timeline Section */} +
+
+ +

版本记录

+
+
+ {sortedVersions.length === 0 ? ( +
+ 暂无版本 +
+ ) : ( + sortedVersions.map((v) => ) + )} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/projects/page.tsx b/apps/web/app/projects/page.tsx new file mode 100644 index 0000000..8fccdc3 --- /dev/null +++ b/apps/web/app/projects/page.tsx @@ -0,0 +1,386 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Search, FolderKanban, Package, ChevronDown, Plus, X } from 'lucide-react'; +import { useProductStore } from '@/stores/useProductStore'; +import { flattenProjects, ProjectWithContext } from '@/lib/derive'; +import { VersionChip } from '@/components/version/VersionChip'; +import { VersionStatus } from '@/lib/version-status'; + +export default function ProjectsPage() { + const router = useRouter(); + const { overview, fetchOverview, createProject } = useProductStore(); + const [search, setSearch] = useState(''); + const [productFilter, setProductFilter] = useState('all'); + const [showForm, setShowForm] = useState(false); + + useEffect(() => { + fetchOverview(); + }, [fetchOverview]); + + const allProjects = useMemo(() => flattenProjects(overview), [overview]); + + const filtered = useMemo(() => { + return allProjects.filter((p) => { + if (productFilter !== 'all' && p.productId !== productFilter) return false; + if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false; + return true; + }); + }, [allProjects, search, productFilter]); + + const productOptions = useMemo( + () => overview.map((p) => ({ id: p.id, name: p.name })), + [overview] + ); + + const activeProductName = + productFilter === 'all' + ? '全部产品' + : productOptions.find((p) => p.id === productFilter)?.name || '全部产品'; + + return ( +
+ {/* Header */} +
+
+

项目

+ + {allProjects.length} + +
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + placeholder="搜索项目名称…" + className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] 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)]" + /> +
+ + + +
+ + + +
+
+ + {/* List */} +
+ {filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((proj) => ( + router.push(`/projects/${proj.id}`)} /> + ))} +
+ )} +
+ + {showForm && ( + setShowForm(false)} + onSubmit={async (productId, name, description) => { + await createProject(productId, { name, description }); + setShowForm(false); + }} + /> + )} +
+ ); +} + +/* ─── ProjectRow ─────────────────────────────────────────────────── */ + +function ProjectRow({ + proj, + onClick, +}: { + proj: ProjectWithContext; + onClick: () => void; +}) { + const activeVersions = proj.versions.filter((v) => v.status !== 'released'); + const releasedCount = proj.versions.filter((v) => v.status === 'released').length; + + return ( + + ); +} + +/* ─── ProductDropdown ────────────────────────────────────────────── */ + +function ProductDropdown({ + value, + options, + onChange, + activeName, +}: { + value: string; + options: { id: string; name: string }[]; + onChange: (v: string) => void; + activeName: string; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ { onChange('all'); setOpen(false); }} + label="全部产品" + /> + {options.map((opt) => ( + { onChange(opt.id); setOpen(false); }} + label={opt.name} + /> + ))} +
+ + )} +
+ ); +} + +function DropdownItem({ + active, + onClick, + label, +}: { + active: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} + +/* ─── LegendDot ──────────────────────────────────────────────────── */ + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +/* ─── EmptyState ─────────────────────────────────────────────────── */ + +function EmptyState() { + return ( +
+ +

暂无匹配项目

+

尝试调整搜索条件或筛选器

+
+ ); +} + +/* ─── CreateProjectModal ─────────────────────────────────────────── */ + +function CreateProjectModal({ + products, + overview, + onClose, + onSubmit, +}: { + products: { id: string; name: string }[]; + overview: any[]; + onClose: () => void; + onSubmit: (productId: string, name: string, description: string) => Promise; +}) { + const [productId, setProductId] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [duplicateError, setDuplicateError] = useState(false); + + const handleSubmit = async () => { + if (!productId || !name.trim()) return; + + const product = overview.find((p: any) => p.id === productId); + if (product) { + const exists = product.projects?.some( + (proj: any) => proj.name === name.trim() + ); + if (exists) { + setDuplicateError(true); + return; + } + } + + setSubmitting(true); + try { + await onSubmit(productId, name.trim(), description.trim()); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

新建项目

+ +
+
+ {/* Product select */} +
+ +
+ + +
+
+ + {/* Name */} +
+ + { setName(e.target.value); setDuplicateError(false); }} + placeholder="请输入项目名称" + className={`h-9 w-full rounded-lg border bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:outline-none focus:ring-2 ${ + duplicateError + ? 'border-red-500 focus:border-red-500 focus:ring-red-100' + : 'border-[var(--line)] focus:border-[var(--accent)] focus:ring-[var(--accent-ring)]' + }`} + /> + {duplicateError && ( +

该产品下已存在同名项目

+ )} +
+ + {/* Description */} +
+ +