Files
ftb-project-management/apps/web/app/workspace/page.tsx
Script Generator 98251f1c38 feat: 与我相关点击打开侧边详情 + 开发/测试转交功能
一、工作台交互改为侧边栏详情(点击卡片打开):
- 调研/产品/UI:新建 PlanDetailDrawer
  - 子任务勾选、进度条、上传成果(链接/文件)
  - 开始/提交完成操作、转交功能
- 开发任务:复用 DevTaskDetailDrawer
  - 状态流转、阻塞管理、前置任务等完整功能
- 测试用例:复用 TestCaseDetailDrawer
  - 测试步骤、预期结果、关联Bug、提Bug
- Bug:复用 BugDetailDrawer
  - 描述、截图、操作日志、状态转换

二、开发任务增加转交功能:
- DevTaskDetailDrawer 顶部增加转交按钮
- 从 members 列表选择新负责人
- 更新 assigneeId

三、测试用例增加转交功能:
- TestCaseDetailDrawer 顶部增加转交按钮
- 人员离职等场景可转交

四、版本详情同步:
- 所有 Drawer 改动同时生效于版本详情页
  (因为复用同一组件)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 17:30:16 +08:00

354 lines
19 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2, ChevronRight, ChevronDown, FolderOpen, Layers } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions } from '@/lib/derive';
import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
import type { WorkItem, WorkItemType } from '@/lib/workspace-engine';
import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer';
import { DevTaskDetailDrawer } from '@/components/dev-task/DevTaskDetailDrawer';
import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer';
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task';
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'all', label: '全部', icon: ClipboardList },
{ key: 'plan_research', label: '调研', icon: Search },
{ key: 'plan_product', label: '产品方案', icon: FileText },
{ key: 'plan_ui', label: 'UI设计', icon: Palette },
{ key: 'devTask', label: '开发任务', icon: Code2 },
{ key: 'testCase', label: '测试用例', icon: ClipboardCheck },
{ key: 'bug', label: 'Bug', icon: BugIcon },
];
const PLAN_STATUS_STYLE: Record<string, string> = {
pending: 'bg-zinc-100 text-zinc-600',
in_progress: 'bg-blue-50 text-blue-600',
completed: 'bg-emerald-50 text-emerald-600',
};
const PLAN_STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
export default function WorkspacePage() {
const router = useRouter();
const { overview, fetchOverview } = useProductStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState<TabKey>('all');
const [showCompleted, setShowCompleted] = useState(true);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [drawerItem, setDrawerItem] = useState<WorkItem | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
const userName = user?.name ?? '';
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const versionMap = useMemo(() => {
const map = new Map<string, { id: string; name: string; productName: string; projectName: string }>();
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName }));
return map;
}, [allVersions]);
const requirementVersionMap = useMemo(() => {
const map = new Map<string, string>();
requirements.forEach((r) => { if (r.versionId) map.set(r.id, r.versionId); });
return map;
}, [requirements]);
const workItems = useMemo(() =>
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
);
// 构建树:只显示跟自己有关的产品/项目/版本
const tree = useMemo(() => {
const myVersionIds = new Set(workItems.map((i) => i.versionId).filter(Boolean));
const productMap = new Map<string, { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> }>();
allVersions.forEach((v) => {
if (!myVersionIds.has(v.id)) return;
if (!productMap.has(v.productName)) productMap.set(v.productName, { name: v.productName, projects: new Map() });
const prod = productMap.get(v.productName)!;
if (!prod.projects.has(v.projectName)) prod.projects.set(v.projectName, { name: v.projectName, versions: [] });
const proj = prod.projects.get(v.projectName)!;
const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length;
if (!proj.versions.find((ver) => ver.id === v.id)) {
proj.versions.push({ id: v.id, name: v.name, pendingCount: pending });
}
});
return productMap;
}, [allVersions, workItems]);
const filteredItems = useMemo(() => {
let items = workItems;
if (selectedVersionId) items = items.filter((i) => i.versionId === selectedVersionId);
if (activeTab !== 'all') items = items.filter((i) => i.type === activeTab);
if (!showCompleted) items = items.filter((i) => !i.completed);
return items.sort((a, b) => (a.completed !== b.completed ? (a.completed ? 1 : -1) : 0));
}, [workItems, activeTab, showCompleted, selectedVersionId]);
// 待办数量按 tab 分(受 version filter 影响)
const pendingByTab = useMemo(() => {
const base = selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems;
return {
all: base.filter((i) => !i.completed).length,
plan_research: base.filter((i) => i.type === 'plan_research' && !i.completed).length,
plan_product: base.filter((i) => i.type === 'plan_product' && !i.completed).length,
plan_ui: base.filter((i) => i.type === 'plan_ui' && !i.completed).length,
devTask: base.filter((i) => i.type === 'devTask' && !i.completed).length,
testCase: base.filter((i) => i.type === 'testCase' && !i.completed).length,
bug: base.filter((i) => i.type === 'bug' && !i.completed).length,
};
}, [workItems, selectedVersionId]);
const pendingCount = pendingByTab.all;
const completedCount = (selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems).filter((i) => i.completed).length;
return (
<div className="flex h-full">
{/* 左侧第一列:产品/项目/版本树 */}
<div className="w-64 shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
<div className="flex h-14 items-center px-4 border-b border-[var(--line)]">
<h1 className="text-[14px] font-semibold text-[var(--ink)]"></h1>
</div>
<div className="flex-1 overflow-y-auto p-2">
<button
onClick={() => setSelectedVersionId(null)}
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-[12px] mb-1 transition-colors ${!selectedVersionId ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<Layers className="h-3 w-3" />
<span className="flex-1 text-left"></span>
{pendingByTab.all > 0 && !selectedVersionId && (
<span className="text-[10px] bg-red-500 text-white rounded-full px-1.5 min-w-[18px] text-center">{pendingByTab.all}</span>
)}
</button>
{Array.from(tree.entries()).map(([prodName, prod]) => (
<ProductNode key={prodName} name={prodName} prod={prod} selectedVersionId={selectedVersionId} onSelect={setSelectedVersionId} />
))}
</div>
</div>
{/* 左侧第二列:环节分类 */}
<div className="w-56 shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
<div className="flex h-14 items-center px-4 border-b border-[var(--line)]">
<div className="flex items-center gap-3 text-[11px]">
<span className="text-[var(--ink-muted)]"> <span className="font-medium text-[var(--ink)]">{pendingCount}</span></span>
<span className="text-[var(--ink-muted)]"> <span className="font-medium text-emerald-600">{completedCount}</span></span>
</div>
</div>
<nav className="flex-1 p-2 space-y-0.5">
{TABS.map((tab) => {
const Icon = tab.icon;
const pending = pendingByTab[tab.key];
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-[12px] transition-colors ${active ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<Icon className="h-3.5 w-3.5" />
<span className="flex-1 text-left">{tab.label}</span>
{pending > 0 && (
<span className="text-[10px] bg-red-500 text-white rounded-full px-1.5 min-w-[18px] text-center">{pending}</span>
)}
</button>
);
})}
</nav>
<div className="p-3 border-t border-[var(--line)]">
<label className="flex items-center gap-2 text-[11px] text-[var(--ink-soft)] cursor-pointer">
<input type="checkbox" checked={showCompleted} onChange={(e) => setShowCompleted(e.target.checked)} className="h-3.5 w-3.5 rounded" />
</label>
</div>
</div>
{/* 右侧:任务列表 */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="flex h-14 shrink-0 items-center border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{TABS.find((t) => t.key === activeTab)?.label}</h2>
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} </span>
{selectedVersionId && (
<span className="ml-3 text-[11px] text-[var(--accent)] bg-[var(--accent-soft)] px-2 py-0.5 rounded-full">
{versionMap.get(selectedVersionId)?.name}
</span>
)}
</header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
{filteredItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
<p className="text-[13px] text-[var(--ink-muted)]"></p>
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<WorkItemCard
key={item.id}
item={item}
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
onClick={() => setDrawerItem(item)}
/>
))}
</div>
)}
</div>
</div>
{/* Detail Drawers */}
{drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && (
<PlanDetailDrawer planId={drawerItem.id} onClose={() => setDrawerItem(null)} />
)}
{drawerItem && drawerItem.type === 'devTask' && (
<DevTaskDetailDrawer taskId={drawerItem.id} allTaskIds={devTasks.map((t) => t.id)} onClose={() => setDrawerItem(null)} />
)}
{drawerItem && drawerItem.type === 'testCase' && (
<TestCaseDetailDrawer testCaseId={drawerItem.id} onClose={() => setDrawerItem(null)} />
)}
{drawerItem && drawerItem.type === 'bug' && (
<BugDetailDrawer bugId={drawerItem.id} onClose={() => setDrawerItem(null)} />
)}
</div>
);
}
function ProductNode({ name, prod, selectedVersionId, onSelect }: {
name: string;
prod: { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> };
selectedVersionId: string | null;
onSelect: (id: string | null) => void;
}) {
const [expanded, setExpanded] = useState(true);
return (
<div className="mb-0.5">
<button onClick={() => setExpanded(!expanded)} className="w-full flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="truncate">{name}</span>
</button>
{expanded && Array.from(prod.projects.entries()).map(([projName, proj]) => (
<ProjectNode key={projName} name={projName} versions={proj.versions} selectedVersionId={selectedVersionId} onSelect={onSelect} />
))}
</div>
);
}
function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
name: string;
versions: { id: string; name: string; pendingCount: number }[];
selectedVersionId: string | null;
onSelect: (id: string | null) => void;
}) {
const [expanded, setExpanded] = useState(true);
return (
<div className="ml-3">
<button onClick={() => setExpanded(!expanded)} className="w-full flex items-center gap-1.5 px-2 py-1 rounded text-[11px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
{expanded ? <ChevronDown className="h-2.5 w-2.5 shrink-0" /> : <ChevronRight className="h-2.5 w-2.5 shrink-0" />}
<FolderOpen className="h-3 w-3 shrink-0 text-[var(--ink-muted)]" />
<span className="truncate">{name}</span>
</button>
{expanded && versions.map((v) => (
<button
key={v.id}
onClick={() => onSelect(selectedVersionId === v.id ? null : v.id)}
className={`w-full flex items-center gap-1.5 ml-5 px-2 py-1 rounded text-[11px] transition-colors ${selectedVersionId === v.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
<span className="flex-1 text-left truncate">{v.name}</span>
{v.pendingCount > 0 && (
<span className="text-[9px] bg-red-500 text-white rounded-full px-1.5 min-w-[16px] text-center">{v.pendingCount}</span>
)}
</button>
))}
</div>
);
}
function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) {
const statusBadge = getStatusBadge(item);
return (
<div onClick={onClick} className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] cursor-pointer ${item.completed ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-3">
{item.completed && <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />}
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)] shrink-0">
{WORK_ITEM_TYPE_LABEL[item.type]}
</span>
{statusBadge}
{item.extra?.taskNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.taskNo}</span>}
{item.extra?.caseNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.caseNo}</span>}
{item.extra?.bugNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.bugNo}</span>}
<span className="text-[13px] font-medium text-[var(--ink)] flex-1 truncate">{item.title}</span>
{item.priority && <span className="text-[10px] text-[var(--ink-muted)] shrink-0">{item.priority}</span>}
{item.extra?.severity && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${BUG_SEVERITY_COLOR[item.extra.severity as keyof typeof BUG_SEVERITY_COLOR] || ''}`}>
{BUG_SEVERITY_LABEL[item.extra.severity as keyof typeof BUG_SEVERITY_LABEL] || ''}
</span>
)}
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="h-6 w-6 flex items-center justify-center rounded text-[var(--ink-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-subtle)] shrink-0" title="跳转到版本详情">
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-2 mt-1.5 text-[11px] text-[var(--ink-muted)]">
<span>{item.productName}</span>
<span className="text-[var(--line)]">/</span>
<span>{item.projectName}</span>
<span className="text-[var(--line)]">/</span>
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
{item.extra?.dueDate && <span className="ml-2"> {item.extra.dueDate}</span>}
{item.extra?.startTime && <span className="ml-2">{item.extra.startTime.slice(0, 16).replace('T', ' ')} {item.extra.endTime?.slice(0, 16).replace('T', ' ')}</span>}
</div>
</div>
);
}
function getStatusBadge(item: WorkItem) {
if (item.type === 'devTask') {
return (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full shrink-0 ${DEV_TASK_STATUS_COLOR[item.status as keyof typeof DEV_TASK_STATUS_COLOR] || 'bg-zinc-100 text-zinc-600'}`}>
{DEV_TASK_STATUS_LABEL[item.status as keyof typeof DEV_TASK_STATUS_LABEL] || item.status}
</span>
);
}
if (item.type === 'testCase') {
return (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full shrink-0 ${TEST_CASE_STATUS_COLOR[item.status as keyof typeof TEST_CASE_STATUS_COLOR] || 'bg-zinc-100 text-zinc-600'}`}>
{TEST_CASE_STATUS_LABEL[item.status as keyof typeof TEST_CASE_STATUS_LABEL] || item.status}
</span>
);
}
if (item.type === 'bug') {
return (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full shrink-0 ${BUG_STATUS_COLOR[item.status as keyof typeof BUG_STATUS_COLOR] || 'bg-zinc-100 text-zinc-600'}`}>
{BUG_STATUS_LABEL[item.status as keyof typeof BUG_STATUS_LABEL] || item.status}
</span>
);
}
return (
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full shrink-0 ${PLAN_STATUS_STYLE[item.status] || 'bg-zinc-100 text-zinc-600'}`}>
{PLAN_STATUS_LABEL[item.status] || item.status}
</span>
);
}