编辑产品
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.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) => (
+ setStatusFilter(opt.key)}
+ className={`px-2.5 py-1 rounded-md text-[11px] font-medium transition-colors ${statusFilter === opt.key ? 'bg-[var(--bg-card)] text-[var(--ink)] shadow-sm' : 'text-[var(--ink-muted)] hover:text-[var(--ink-soft)]'}`}
+ >
+ {opt.label}
+
+ ))}
+
-
+
{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 {}