Files
ftb-project-management/apps/web/app/workspace/page.tsx
Script Generator 7fe3a3854a feat(test-case+bug): 测试用例模块 + Bug 模块完整实现
需求验收体系:
- 测试用例:五态状态机(待执行/执行中/通过/失败/阻塞)
- Bug:六态状态机(待修复/修复中/已修复/验证中/已关闭/已拒绝)
- Bug 通过测试用例间接关联需求(不冗余存版本)
- Bug 默认修复人 = 关联需求的开发任务负责人
- 测试进度 = 已执行用例/总用例, 通过率 = 通过/已执行

UI:
- 版本详情页新增"测试用例" Tab + "BUG" Tab
- 测试用例:统计栏+筛选+按需求分组列表+详情抽屉+提BUG入口
- Bug:统计栏+筛选+列表+详情抽屉(链式跳转用例→需求)
- 概览胶囊"测试"阶段进度联动
- "与我相关"新增测试用例/Bug分组

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 13:11:08 +08:00

445 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp, Code2, ClipboardCheck, Bug as BugIcon } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions } from '@/lib/derive';
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours } 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';
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
import type { DevTask } from '@/lib/dev-task';
type TabKey = 'all' | 'research' | 'product' | 'ui' | 'devTask' | 'testCase' | 'bug';
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'all', label: '全部待办', icon: ClipboardList },
{ key: 'research', label: '调研', icon: Search },
{ key: 'product', label: '产品方案', icon: FileText },
{ key: 'ui', label: 'UI设计', icon: Palette },
{ key: 'devTask', label: '开发任务', icon: Code2 },
{ key: 'testCase', label: '测试用例', icon: ClipboardCheck },
{ key: 'bug', label: 'Bug', icon: BugIcon },
];
export default function WorkspacePage() {
const router = useRouter();
const { overview, fetchOverview } = useProductStore();
const { plans, fetchPlans, updatePlan, completePlan } = useVersionPlanStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { categories, fetchCategories } = useTaskCategoryStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState<TabKey>('all');
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
const userName = user?.name ?? '';
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
// 我负责的所有未完成计划
const myPlans = useMemo(() =>
plans.filter((p) => p.owner === userName && p.status !== 'completed'),
[plans, userName]
);
// 我负责的所有未完成开发任务
const myDevTasks = useMemo(() =>
devTasks.filter((t) => t.assigneeId === userName && t.status !== 'done'),
[devTasks, userName]
);
// 我负责的待执行/执行中/失败测试用例
const myTestCases = useMemo(() =>
testCases.filter((c) => c.assigneeId === userName && (c.status === 'pending' || c.status === 'running' || c.status === 'failed')),
[testCases, userName]
);
// 我负责的未关闭 Bug
const myBugs = useMemo(() =>
bugs.filter((b) => b.assigneeId === userName && b.status !== 'closed' && b.status !== 'rejected'),
[bugs, userName]
);
const counts = {
all: myPlans.length + myDevTasks.length + myTestCases.length + myBugs.length,
research: myPlans.filter((p) => p.type === 'research').length,
product: myPlans.filter((p) => p.type === 'product').length,
ui: myPlans.filter((p) => p.type === 'ui').length,
devTask: myDevTasks.length,
testCase: myTestCases.length,
bug: myBugs.length,
};
const filtered = activeTab === 'all' ? myPlans : (activeTab === 'devTask' || activeTab === 'testCase' || activeTab === 'bug') ? [] : myPlans.filter((p) => p.type === activeTab);
const today = new Date().toISOString().slice(0, 10);
const toggleTask = (plan: VersionPlan, task: PlanTask) => {
const next: PlanTask['status'] = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: next } : t);
updatePlan(plan.id, { tasks: updatedTasks });
};
return (
<div className="flex h-full">
{/* 左侧:分组 */}
<div className="w-60 shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
<div className="flex h-14 items-center px-5 border-b border-[var(--line)]">
<h1 className="text-[15px] font-semibold text-[var(--ink)]"></h1>
</div>
<nav className="flex-1 p-3 space-y-1">
{TABS.map((tab) => {
const Icon = tab.icon;
const count = counts[tab.key];
const active = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] 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>
<span className={`text-[11px] tabular-nums px-1.5 py-0.5 rounded ${active ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>
{count}
</span>
</button>
);
})}
</nav>
</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)]">{activeTab === 'devTask' ? myDevTasks.length : activeTab === 'testCase' ? myTestCases.length : activeTab === 'bug' ? myBugs.length : filtered.length} </span>
</header>
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)] space-y-3">
{activeTab === 'devTask' ? (
myDevTasks.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>
) : (
myDevTasks.map((task) => {
const cat = categories.find((c) => c.id === task.categoryId);
const req = requirements.find((r) => r.id === task.requirementId);
return (
<div key={task.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${DEV_TASK_STATUS_COLOR[task.status]}`}>
{DEV_TASK_STATUS_LABEL[task.status]}
</span>
{task.isBlocked && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded"></span>}
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
<span className="text-[14px] font-medium text-[var(--ink)]">{task.title}</span>
</div>
<span className="text-[11px] text-[var(--ink-muted)]">{task.priority}</span>
</div>
<div className="flex items-center gap-4 text-[11px] text-[var(--ink-muted)]">
{req && <span>{req.code} {req.title}</span>}
{cat && <span className="px-1.5 py-0.5 rounded text-[10px]" style={{ backgroundColor: cat.color ? `${cat.color}15` : undefined, color: cat.color }}>{cat.name}</span>}
<span> {formatHours(task.estimateHours)}</span>
{task.actualHours > 0 && <span> {task.actualHours}h</span>}
{task.dueDate && <span> {task.dueDate}</span>}
</div>
</div>
);
})
)
) : activeTab === 'testCase' ? (
myTestCases.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>
) : (
myTestCases.map((tc) => (
<div key={tc.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${TEST_CASE_STATUS_COLOR[tc.status]}`}>
{TEST_CASE_STATUS_LABEL[tc.status]}
</span>
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
<span className="text-[14px] font-medium text-[var(--ink)]">{tc.title}</span>
</div>
<span className="text-[11px] text-[var(--ink-muted)]">{tc.priority}</span>
</div>
</div>
))
)
) : activeTab === 'bug' ? (
myBugs.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)]"> Bug</p>
</div>
) : (
myBugs.map((bug) => (
<div key={bug.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${BUG_STATUS_COLOR[bug.status]}`}>
{BUG_STATUS_LABEL[bug.status]}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{bug.bugNo}</span>
<span className="text-[14px] font-medium text-[var(--ink)]">{bug.title}</span>
</div>
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
</div>
</div>
))
)
) : filtered.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>
) : (
filtered.map((plan) => {
const version = allVersions.find((v) => v.id === plan.versionId);
const isActive = plan.startTime <= today;
const typeLabel = plan.type === 'research' ? '调研' : plan.type === 'product' ? '产品方案' : 'UI设计';
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
return (
<PlanCard
key={plan.id}
plan={plan}
versionName={version?.name}
versionInfo={version ? `${version.productName} / ${version.projectName}` : '-'}
versionId={version?.id}
typeLabel={typeLabel}
isActive={isActive}
linkedReqs={linkedReqs}
onToggleTask={(task) => toggleTask(plan, task)}
onToggleReq={(reqId) => {
const current = plan.completedRequirementIds || [];
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
updatePlan(plan.id, { completedRequirementIds: next });
}}
onAddTask={(title) => {
const newTask: PlanTask = { id: `task-${Date.now()}`, title, status: 'pending' };
updatePlan(plan.id, { tasks: [...(plan.tasks || []), newTask] });
}}
onComplete={() => setCompletingPlan(plan)}
onJumpVersion={() => version && router.push(`/versions/${version.id}`)}
onUpdate={(data) => updatePlan(plan.id, data)}
/>
);
})
)}
</div>
</div>
{completingPlan && (
<CompleteModal
onClose={() => setCompletingPlan(null)}
onSubmit={(result) => {
completePlan(completingPlan.id, result);
setCompletingPlan(null);
}}
/>
)}
</div>
);
}
function PlanCard({ plan, versionName, versionInfo, versionId, typeLabel, isActive, linkedReqs, onToggleTask, onToggleReq, onAddTask, onComplete, onJumpVersion, onUpdate }: {
plan: VersionPlan;
versionName?: string;
versionInfo: string;
versionId?: string;
typeLabel: string;
isActive: boolean;
linkedReqs: { id: string; code: string; title: string }[];
onToggleTask: (task: PlanTask) => void;
onToggleReq: (reqId: string) => void;
onAddTask: (title: string) => void;
onComplete: () => void;
onJumpVersion: () => void;
onUpdate: (data: Partial<VersionPlan>) => void;
}) {
const [newTaskTitle, setNewTaskTitle] = useState('');
const isResearch = plan.type === 'research';
const progress = isResearch
? calcPlanProgress(plan.tasks)
: calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
const allReqsDone = !isResearch && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && progress === 100;
return (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${isActive ? 'bg-blue-50 text-blue-600' : 'bg-zinc-100 text-zinc-500'}`}>
{isActive ? '进行中' : '未开始'}
</span>
<span className="text-[10px] text-[var(--ink-muted)] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)]">{typeLabel}</span>
<span className="text-[14px] font-medium text-[var(--ink)]">{plan.title}</span>
</div>
<div className="flex items-center gap-2">
{(allReqsDone || (isResearch && plan.tasks && plan.tasks.length > 0)) && (
<button onClick={onComplete} className="h-7 px-3 rounded-md text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
</button>
)}
</div>
</div>
<div className="flex items-center gap-4 text-[11px] text-[var(--ink-muted)] mb-3">
<span>{versionInfo} / </span>
<button onClick={onJumpVersion} className="text-[var(--accent)] hover:underline">{versionName || '版本'}</button>
<span>{plan.startTime.slice(0, 10)} {plan.endTime.slice(0, 10)}</span>
{progress > 0 && <span className="font-medium text-[var(--ink-soft)]">{progress}%</span>}
</div>
{/* 进度条 */}
{((isResearch && plan.tasks && plan.tasks.length > 0) || (!isResearch && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0)) && (
<div className="mb-3 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
)}
{/* 调研:任务清单 */}
{isResearch && (
<div className="space-y-1.5">
{(plan.tasks || []).map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-[var(--bg-subtle)]">
<button
onClick={() => onToggleTask(task)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' :
task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' :
'border-[var(--line)]'
}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
{task.status === 'in_progress' && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
</button>
<span className={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>
{task.title}
</span>
<span className={`text-[10px] ${task.status === 'completed' ? 'text-green-600' : task.status === 'in_progress' ? 'text-blue-600' : 'text-[var(--ink-muted)]'}`}>
{task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'}
</span>
</div>
))}
<div className="flex gap-2 mt-2">
<input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && newTaskTitle.trim()) { onAddTask(newTaskTitle.trim()); setNewTaskTitle(''); } }}
placeholder="添加任务,回车确认"
className="flex-1 h-7 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
</div>
</div>
)}
{/* 产品方案/UI关联需求清单 */}
{!isResearch && linkedReqs.length > 0 && (
<div className="space-y-1.5">
{linkedReqs.map((req) => {
const isDone = (plan.completedRequirementIds || []).includes(req.id);
return (
<div key={req.id} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-[var(--bg-subtle)]">
<button
onClick={() => onToggleReq(req.id)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req.code}</span>
<span className={`flex-1 text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
</div>
);
})}
{allReqsDone && (
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 text-[12px] text-green-700">
</div>
)}
</div>
)}
{!isResearch && linkedReqs.length === 0 && (
<div className="text-[12px] text-[var(--ink-muted)] py-2">
<button onClick={onJumpVersion} className="text-[var(--accent)] hover:underline"></button>
</div>
)}
</div>
);
}
function CompleteModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [fileData, setFileData] = useState('');
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => setFileData(reader.result as string);
reader.readAsDataURL(file);
};
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<h3 className="text-[13px] font-semibold text-[var(--ink)] mb-4"></h3>
<div className="space-y-3">
<div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" /></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" /></button>
</div>
{resultType === 'link' ? (
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
</div>
);
}