From 24ba61f92943615bc900e967be03bb0006fd0719 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 8 Jun 2026 18:44:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(projects):=20=E9=87=8D=E6=9E=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=AF=A6=E6=83=85=E9=A1=B5=20=E2=80=94=20=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E5=88=86=E6=AE=B5=E6=9D=A1=E3=80=81=E7=B4=A7=E5=87=91?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E5=8D=A1=E7=89=87=E3=80=81=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用胶囊分段条替代圆点流水线,合并阶段+进度+耗时为一体 - 版本状态标签与胶囊当前阶段联动(如"开发中"对应开发阶段) - 已发布版本可展开查看各阶段耗时胶囊条 - 项目人员区域紧凑化,超出4人省略+hover tooltip - 版本记录增加按阶段筛选(调研/产品设计/UI设计/开发/联调/测试/已发布/规划中) - 全局详情页左右留白统一为 px-5 - 修复 localStorage 缓存导致人员数据不显示的问题 - "需求"阶段更名为"调研" Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/products/[id]/page.tsx | 2 +- apps/web/app/projects/[id]/page.tsx | 437 ++++++++++++++-------------- apps/web/lib/stage.ts | 2 +- apps/web/stores/useProductStore.ts | 2 +- 4 files changed, 227 insertions(+), 216 deletions(-) diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index 14388ee..e525088 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -134,7 +134,7 @@ export default function ProductDetailPage() {
-
+
{editingProduct && (

编辑产品

diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index 1fe36b2..3a70723 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -1,24 +1,13 @@ 'use client'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { ChevronLeft, Package, Calendar, Clock, Users, Tag } from 'lucide-react'; +import { ChevronLeft, Package, Calendar, Clock, Users, Tag, ChevronDown } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; -import { getProjectDetail } from '@/lib/derive'; +import { getProjectDetail, VersionWithContext } 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 ( @@ -29,16 +18,13 @@ function StatCard({ value, label }: { value: number | string; label: string }) { ); } -/* ─── ProgressBar ─── */ +/* ─── ProgressBar (for expanded released cards) ─── */ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) { return (
{ROLE_LABEL[role]}
-
+
{percent}% @@ -48,59 +34,67 @@ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number ); } -/* ─── StagePipeline ─── */ -function StagePipeline({ currentStage }: { currentStage?: Stage }) { - const currentIdx = currentStage !== undefined ? STAGE_INDEX[currentStage] : -1; +/* ─── CapsuleStages (胶囊分段条) ─── */ +function CapsuleStages({ currentStage, progress }: { + currentStage?: Stage; + progress?: { role: Role; percent: number; daysSpent: number }[]; +}) { + const currentIdx = currentStage !== undefined + ? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage]) + : -1; + const progressMap = (progress ?? []).reduce>((acc, p) => { + acc[p.role] = { percent: p.percent, daysSpent: p.daysSpent }; + return acc; + }, {} as Record); - const stageLabel: Record = { - requirement: '需求', - product_design: '产品设计', - ui_design: 'UI设计', - dev: '开发', - integration: '联调', - testing: '测试', - released: '已发布', + const stageRoleMap: Record = { + requirement: ['product'], + product_design: ['product'], + ui_design: ['ui'], + dev: ['frontend', 'backend'], + integration: ['frontend', 'backend'], + testing: ['testing'], + released: [], }; + function getStageInfo(stage: Stage) { + const roles = stageRoleMap[stage]; + if (roles.length === 0) return { percent: 0, days: 0, hasData: false }; + const items = roles.map((r) => progressMap[r]).filter(Boolean); + if (items.length === 0) return { percent: 0, days: 0, hasData: false }; + const percent = Math.round(items.reduce((s, i) => s + i.percent, 0) / items.length); + const days = Math.max(...items.map((i) => i.daysSpent)); + return { percent, days, hasData: true }; + } + return ( -
+
{STAGES.map((stage, idx) => { const isCompleted = idx < currentIdx; const isCurrent = idx === currentIdx; - const isFuture = idx > currentIdx; + const info = getStageInfo(stage.key); return ( -
-
- {isCurrent ? ( -
-
-
-
- ) : isCompleted ? ( -
- ) : ( -
- )} - +
+
+ {stage.label} + + {isCompleted ? (info.hasData ? `${info.days}天` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''} + +
+
+ {isCompleted &&
} + {isCurrent && info.hasData && ( +
+
+
+ )}
- {idx < STAGES.length - 1 && ( -
- )}
); })} @@ -108,144 +102,199 @@ function StagePipeline({ currentStage }: { currentStage?: Stage }) { ); } +/* ─── MemberChips (compact inline for version cards) ─── */ +function MemberChips({ members }: { members: { role: Role; name: string }[] }) { + if (!members || members.length === 0) return null; + const grouped = ROLES.reduce>((acc, r) => { + acc[r.key] = members.filter((m) => m.role === r.key).map((m) => m.name); + return acc; + }, {} as Record); + return ( +
+ {ROLES.map((r) => { + const names = grouped[r.key]; + if (!names || names.length === 0) return null; + return ( + + {r.label} + {names.join('/')} + + ); + })} +
+ ); +} + /* ─── VersionCard ─── */ function VersionCard({ version }: { version: VersionWithContext }) { - const statusBg = VERSION_STATUS_BG[version.status]; - const statusLabel = VERSION_STATUS_LABEL[version.status]; + const [expanded, setExpanded] = useState(false); + const totalDays = (version.progress ?? []).reduce((sum, p) => sum + p.daysSpent, 0); + + const stageLabel = version.currentStage ? STAGES.find((s) => s.key === version.currentStage)?.label ?? '' : ''; + const displayStatus = version.status === 'released' ? '已发布' : version.status === 'planned' ? '规划中' : stageLabel ? `${stageLabel}中` : '开发中'; + const displayBg = version.status === 'released' ? 'bg-zinc-100 text-zinc-600' : version.status === 'planned' ? 'bg-orange-500/10 text-orange-600' : 'bg-blue-500/10 text-blue-600'; - // Mode: planned if (version.status === 'planned') { return ( -
+
- {version.name} - - {statusLabel} - - 暂无详情 + {version.name} + {displayStatus} + 暂无详情
); } - 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(' · ')} + + + {expanded && ( +
+ +
)}
); } - // Mode: developing return ( -
+
- {version.name} - - {statusLabel} + {version.name} + {displayStatus} +
+
+
+ + + {version.startDate ?? '-'} → 预计 {version.expectedReleaseDate ?? '-'} + + + 已耗时 {totalDays} 天
- -
- -
- -
-
- - - {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 ─── */ +/* ─── TeamSection (compact, overflow with tooltip) ─── */ +function TeamSection({ teamByRole }: { teamByRole: Record> }) { + const MAX_VISIBLE = 4; + return ( +
+
+ +

项目人员

+
+
+
+ {ROLES.map((role) => { + const peopleMap = teamByRole[role.key] || {}; + const people = Object.entries(peopleMap).sort((a, b) => (b[1] as number) - (a[1] as number)); + if (people.length === 0) return null; + const visible = people.slice(0, MAX_VISIBLE); + const hidden = people.slice(MAX_VISIBLE); + return ( +
+ {role.label} +
+ {visible.map(([name, count]) => ( + + {name}{'×'}{count as number} + + ))} + {hidden.length > 0 && ( + + + +{hidden.length} + + + + {hidden.map(([n, c]) => `${n}(×${c})`).join(', ')} + + + + + )} +
+
+ ); + })} +
+
+
+ ); +} + +/* ─── Status Filter ─── */ +const FILTER_OPTIONS: { key: string; label: string }[] = [ + { key: 'all', label: '全部' }, + { key: 'requirement', label: '调研' }, + { key: 'product_design', label: '产品设计' }, + { key: 'ui_design', label: 'UI设计' }, + { key: 'dev', label: '开发' }, + { key: 'integration', label: '联调' }, + { key: 'testing', label: '测试' }, + { key: 'released', label: '已发布' }, + { key: 'planned', label: '规划中' }, +]; + +/* ─── Main Page ─── */ export default function ProjectDetailPage() { const params = useParams(); const router = useRouter(); const projectId = params.id as string; const { overview, fetchOverview } = useProductStore(); + const [statusFilter, setStatusFilter] = useState('all'); - useEffect(() => { - fetchOverview(); - }, [fetchOverview]); + useEffect(() => { fetchOverview(); }, [fetchOverview]); - const project = useMemo( - () => getProjectDetail(overview, projectId), - [overview, projectId] - ); + 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]); + let list = [...project.versions]; + if (statusFilter !== 'all') { + if (statusFilter === 'planned') { + list = list.filter((v) => v.status === 'planned'); + } else if (statusFilter === 'released') { + list = list.filter((v) => v.status === 'released'); + } else { + list = list.filter((v) => v.status === 'developing' && v.currentStage === statusFilter); + } + } + return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [project, statusFilter]); 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); + const totalDays = project.versions.reduce((sum, v) => 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; + if (!project) return {} as Record>; + const map: Record> = {}; ROLES.forEach((r) => (map[r.key] = {})); project.versions.forEach((v) => { (v.members ?? []).forEach((m) => { @@ -260,43 +309,28 @@ export default function ProjectDetailPage() { return (

项目不存在

- +
); } return (
- {/* Header */}
- / - - {project.name} - + {project.name}
- - {project.productName} + {project.productName}
- {/* Content */}
-
- {/* Overview Stats Row */} +
@@ -304,52 +338,29 @@ export default function ProjectDetailPage() {
- {/* 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 */}
-
- -

版本记录

+
+
+ +

版本记录

+
+
+ {FILTER_OPTIONS.map((opt) => ( + + ))} +
-
+
{sortedVersions.length === 0 ? ( -
- 暂无版本 -
+
暂无版本
) : ( sortedVersions.map((v) => ) )} diff --git a/apps/web/lib/stage.ts b/apps/web/lib/stage.ts index f24e5d6..a7e3e74 100644 --- a/apps/web/lib/stage.ts +++ b/apps/web/lib/stage.ts @@ -3,7 +3,7 @@ export type Stage = 'requirement' | 'product_design' | 'ui_design' | 'dev' | 'in export type Role = 'product' | 'ui' | 'frontend' | 'backend' | 'testing'; export const STAGES: { key: Stage; label: string }[] = [ - { key: 'requirement', label: '需求' }, + { key: 'requirement', label: '调研' }, { key: 'product_design', label: '产品设计' }, { key: 'ui_design', label: 'UI 设计' }, { key: 'dev', label: '开发' }, diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts index ab17c57..eccaacd 100644 --- a/apps/web/stores/useProductStore.ts +++ b/apps/web/stores/useProductStore.ts @@ -290,7 +290,7 @@ export const useProductStore = create((set, get) => ({ }, })); -const STORAGE_KEY = 'ftb_products_overview'; +const STORAGE_KEY = 'ftb_products_overview_v2'; function saveLocal(data: ProductOverview[]) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch {}