功能开发中,敬请期待
diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx
index cf370c2..4c0b7a3 100644
--- a/apps/web/app/workspace/page.tsx
+++ b/apps/web/app/workspace/page.tsx
@@ -2,20 +2,24 @@
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
-import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp, Code2 } from 'lucide-react';
+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';
+type TabKey = 'all' | 'research' | 'product' | 'ui' | 'devTask' | 'testCase' | 'bug';
const TABS: { key: TabKey; label: string; icon: any }[] = [
{ key: 'all', label: '全部待办', icon: ClipboardList },
@@ -23,6 +27,8 @@ const TABS: { key: TabKey; label: string; icon: any }[] = [
{ 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() {
@@ -32,6 +38,8 @@ export default function WorkspacePage() {
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
('all');
const [completingPlan, setCompletingPlan] = useState(null);
@@ -41,6 +49,8 @@ export default function WorkspacePage() {
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]);
@@ -57,15 +67,29 @@ export default function WorkspacePage() {
[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,
+ 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' ? [] : myPlans.filter((p) => p.type === activeTab);
+ 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) => {
@@ -113,7 +137,7 @@ export default function WorkspacePage() {
{TABS.find((t) => t.key === activeTab)?.label}
- {activeTab === 'devTask' ? myDevTasks.length : filtered.length} 项
+ {activeTab === 'devTask' ? myDevTasks.length : activeTab === 'testCase' ? myTestCases.length : activeTab === 'bug' ? myBugs.length : filtered.length} 项
@@ -150,6 +174,49 @@ export default function WorkspacePage() {
);
})
)
+ ) : activeTab === 'testCase' ? (
+ myTestCases.length === 0 ? (
+
+ ) : (
+ myTestCases.map((tc) => (
+
+
+
+
+ {TEST_CASE_STATUS_LABEL[tc.status]}
+
+ {tc.caseNo}
+ {tc.title}
+
+
{tc.priority}
+
+
+ ))
+ )
+ ) : activeTab === 'bug' ? (
+ myBugs.length === 0 ? (
+
+ ) : (
+ myBugs.map((bug) => (
+
+
+
+
+ {BUG_STATUS_LABEL[bug.status]}
+
+ {BUG_SEVERITY_LABEL[bug.severity]}
+ {bug.bugNo}
+ {bug.title}
+
+
{bug.priority}
+
+
+ ))
+ )
) : filtered.length === 0 ? (
暂无待办事项
diff --git a/apps/web/components/bug/BugCreateModal.tsx b/apps/web/components/bug/BugCreateModal.tsx
new file mode 100644
index 0000000..9ffdc86
--- /dev/null
+++ b/apps/web/components/bug/BugCreateModal.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import { X } from 'lucide-react';
+import { useBugStore } from '@/stores/useBugStore';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useDevTaskStore } from '@/stores/useDevTaskStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { useMemberStore } from '@/stores/useMemberStore';
+import { useAuthStore } from '@/stores/useAuthStore';
+import type { Priority } from '@/lib/derive';
+import type { BugSeverity } from '@/lib/bug';
+
+interface Props {
+ testCaseId: string;
+ onClose: () => void;
+}
+
+export function BugCreateModal({ testCaseId, onClose }: Props) {
+ const { createBug } = useBugStore();
+ const { testCases } = useTestCaseStore();
+ const { tasks: devTasks } = useDevTaskStore();
+ const { requirements } = useRequirementStore();
+ const { members } = useMemberStore();
+ const user = useAuthStore((s) => s.user);
+
+ const tc = testCases.find((c) => c.id === testCaseId);
+ const requirement = requirements.find((r) => r.id === tc?.requirementId);
+
+ // 推导默认负责人
+ const defaultAssignee = useMemo(() => {
+ if (!tc) return '';
+ const reqTasks = devTasks.filter((t) => t.requirementId === tc.requirementId);
+ const inProgress = reqTasks.filter((t) => t.status === 'in_progress').sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ if (inProgress.length > 0) return inProgress[0].assigneeId;
+ const latest = reqTasks.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ return latest.length > 0 ? latest[0].assigneeId : '';
+ }, [tc, devTasks]);
+
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [severity, setSeverity] = useState
('major');
+ const [priority, setPriority] = useState(tc?.priority || 'P1');
+ const [assigneeId, setAssigneeId] = useState(defaultAssignee);
+
+ const canSubmit = title.trim() && description.trim() && assigneeId;
+
+ const handleSubmit = () => {
+ if (!canSubmit) return;
+ createBug({
+ testCaseId,
+ title: title.trim(),
+ description: description.trim(),
+ severity,
+ priority,
+ reportedBy: user?.name || '系统',
+ assigneeId,
+ });
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
提交 Bug
+
+
+
+ {/* 只读关联信息 */}
+
+
关联用例:{tc?.caseNo} {tc?.title}
+ {requirement &&
关联需求:{requirement.code} {requirement.title}
}
+
+
+
+
+
+ setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="简要描述问题" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/bug/BugDetailDrawer.tsx b/apps/web/components/bug/BugDetailDrawer.tsx
new file mode 100644
index 0000000..4ba6dbe
--- /dev/null
+++ b/apps/web/components/bug/BugDetailDrawer.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { useState } from 'react';
+import { X, Link2, ChevronRight } from 'lucide-react';
+import { BugStatusBadge } from './BugStatusBadge';
+import { useBugStore } from '@/stores/useBugStore';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
+import type { BugStatus } from '@/lib/bug';
+
+interface Props {
+ bugId: string;
+ onClose: () => void;
+}
+
+export function BugDetailDrawer({ bugId, onClose }: Props) {
+ const { bugs, changeStatus } = useBugStore();
+ const { testCases } = useTestCaseStore();
+ const { requirements } = useRequirementStore();
+
+ const bug = bugs.find((b) => b.id === bugId);
+ if (!bug) return null;
+
+ const tc = testCases.find((c) => c.id === bug.testCaseId);
+ const requirement = tc ? requirements.find((r) => r.id === tc.requirementId) : null;
+ const nextStatuses = BUG_ALLOWED_TRANSITIONS[bug.status];
+
+ const [resolution, setResolution] = useState(bug.resolution || '');
+ const [showResolutionInput, setShowResolutionInput] = useState(false);
+
+ const handleTransition = (to: BugStatus) => {
+ if (to === 'fixed') { setShowResolutionInput(true); return; }
+ changeStatus(bug.id, to);
+ };
+
+ const confirmFix = () => {
+ changeStatus(bug.id, 'fixed', { resolution: resolution.trim() || undefined });
+ setShowResolutionInput(false);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ {bug.bugNo}
+ {bug.title}
+
+
+
+
+
+ {/* 关联链路 */}
+
+
关联链路
+ {tc && (
+
+
+ {tc.caseNo}
+ {tc.title}
+
+ )}
+ {requirement && (
+
+
+ {requirement.code}
+ {requirement.title}
+
+ )}
+
+
+ {/* 状态 & 操作 */}
+
+
状态 & 操作
+
+
+ {BUG_SEVERITY_LABEL[bug.severity]}
+ {bug.priority}
+
+
+ {nextStatuses.length > 0 && !showResolutionInput && (
+
+
+ {nextStatuses.map((s) => (
+
+ ))}
+
+ )}
+
+ {showResolutionInput && (
+
+
setResolution(e.target.value)} placeholder="修复说明" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" autoFocus />
+
+
+
+
+
+ )}
+
+
+ {/* 基本信息 */}
+
+
基本信息
+
+
提交人:{bug.reportedBy}
+
修复人:{bug.assigneeId}
+
提交:{bug.createdAt.slice(0, 10)}
+ {bug.resolvedAt &&
修复:{bug.resolvedAt}
}
+ {bug.closedAt &&
关闭:{bug.closedAt}
}
+
+
+
+ {/* 描述 */}
+
+
Bug 描述
+
{bug.description}
+
+
+ {/* 修复说明 */}
+ {bug.resolution && (
+
+
修复说明
+
{bug.resolution}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/components/bug/BugRow.tsx b/apps/web/components/bug/BugRow.tsx
new file mode 100644
index 0000000..9568b5c
--- /dev/null
+++ b/apps/web/components/bug/BugRow.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { BugStatusBadge } from './BugStatusBadge';
+import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
+import type { Bug } from '@/lib/bug';
+
+interface Props {
+ bug: Bug;
+ testCaseNo?: string;
+ onClick?: () => void;
+}
+
+const PRIORITY_DOT: Record = {
+ P0: 'bg-red-500',
+ P1: 'bg-orange-400',
+ P2: 'bg-blue-400',
+ P3: 'bg-zinc-300',
+};
+
+export function BugRow({ bug, testCaseNo, onClick }: Props) {
+ return (
+
+
+ {bug.bugNo}
+ {bug.title}
+ {BUG_SEVERITY_LABEL[bug.severity]}
+
+ {testCaseNo && {testCaseNo}}
+ {bug.assigneeId}
+
+ );
+}
diff --git a/apps/web/components/bug/BugStatusBadge.tsx b/apps/web/components/bug/BugStatusBadge.tsx
new file mode 100644
index 0000000..8676ccc
--- /dev/null
+++ b/apps/web/components/bug/BugStatusBadge.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { BUG_STATUS_LABEL, BUG_STATUS_COLOR } from '@/lib/bug';
+import type { BugStatus } from '@/lib/bug';
+
+export function BugStatusBadge({ status }: { status: BugStatus }) {
+ return (
+
+ {BUG_STATUS_LABEL[status]}
+
+ );
+}
diff --git a/apps/web/components/bug/BugTab.tsx b/apps/web/components/bug/BugTab.tsx
new file mode 100644
index 0000000..175571d
--- /dev/null
+++ b/apps/web/components/bug/BugTab.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useState, useMemo, useEffect } from 'react';
+import { Bug as BugIcon, Filter } from 'lucide-react';
+import { useBugStore } from '@/stores/useBugStore';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { useAuthStore } from '@/stores/useAuthStore';
+import { BugRow } from './BugRow';
+import { BugDetailDrawer } from './BugDetailDrawer';
+import { Pagination, usePagination } from '@/components/Pagination';
+import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL } from '@/lib/bug';
+import type { BugStatus, BugSeverity } from '@/lib/bug';
+
+interface Props {
+ versionId: string;
+ requirementIds: string[];
+}
+
+export function BugTab({ versionId, requirementIds }: Props) {
+ const { bugs, fetchBugs } = useBugStore();
+ const { testCases, fetchTestCases } = useTestCaseStore();
+ const { requirements } = useRequirementStore();
+ const user = useAuthStore((s) => s.user);
+
+ useEffect(() => { fetchBugs(); }, [fetchBugs]);
+ useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
+
+ // 该版本的所有 Bug(通过测试用例 → 需求 关联)
+ const versionCases = useMemo(
+ () => testCases.filter((c) => requirementIds.includes(c.requirementId)),
+ [testCases, requirementIds],
+ );
+ const versionCaseIds = useMemo(() => new Set(versionCases.map((c) => c.id)), [versionCases]);
+ const versionBugs = useMemo(
+ () => bugs.filter((b) => versionCaseIds.has(b.testCaseId)),
+ [bugs, versionCaseIds],
+ );
+
+ // 统计
+ const openCount = versionBugs.filter((b) => b.status === 'open').length;
+ const fixingCount = versionBugs.filter((b) => b.status === 'fixing').length;
+ const fixedCount = versionBugs.filter((b) => b.status === 'fixed' || b.status === 'verifying').length;
+ const closedCount = versionBugs.filter((b) => b.status === 'closed' || b.status === 'rejected').length;
+ const criticalCount = versionBugs.filter((b) => b.severity === 'critical' && b.status !== 'closed' && b.status !== 'rejected').length;
+ const resolveRate = versionBugs.length > 0 ? Math.round(((fixedCount + closedCount) / versionBugs.length) * 100) : 0;
+
+ // 筛选
+ const [filterAssignee, setFilterAssignee] = useState('');
+ const [filterStatus, setFilterStatus] = useState('');
+ const [filterSeverity, setFilterSeverity] = useState('');
+
+ const filteredBugs = useMemo(() => {
+ let result = versionBugs;
+ if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
+ if (filterStatus) result = result.filter((b) => b.status === filterStatus);
+ if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
+ return result;
+ }, [versionBugs, filterAssignee, filterStatus, filterSeverity]);
+
+ const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
+
+ const [selectedBugId, setSelectedBugId] = useState(null);
+
+ const assignees = useMemo(() => {
+ const names = new Set(versionBugs.map((b) => b.assigneeId));
+ return Array.from(names);
+ }, [versionBugs]);
+
+ const hasFilter = !!(filterAssignee || filterStatus || filterSeverity);
+
+ return (
+
+ {/* 统计栏 */}
+
+
+
+ Bug {versionBugs.length} 个
+
+
+ 待修复 {openCount} · 修复中 {fixingCount} · 已修复 {fixedCount} · 已关闭 {closedCount}
+
+ {criticalCount > 0 && (
+
{criticalCount} 致命
+ )}
+
解决率 {resolveRate}%
+
+
+ {/* 筛选 */}
+
+
+
+
+
+ {hasFilter && }
+ {filteredBugs.length} 条
+
+
+ {/* 列表 */}
+ {filteredBugs.length === 0 ? (
+
+
{hasFilter ? '没有匹配的 Bug' : '暂无 Bug,很好'}
+
+ ) : (
+
+ {paged.map((bug) => {
+ const tc = testCases.find((c) => c.id === bug.testCaseId);
+ return setSelectedBugId(bug.id)} />;
+ })}
+
+ )}
+
+ {total > 20 &&
}
+
+ {selectedBugId &&
setSelectedBugId(null)} />}
+
+ );
+}
diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx
new file mode 100644
index 0000000..77a8f17
--- /dev/null
+++ b/apps/web/components/test-case/TestCaseCreateModal.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import { X } from 'lucide-react';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { useMemberStore } from '@/stores/useMemberStore';
+import { useAuthStore } from '@/stores/useAuthStore';
+import type { Priority } from '@/lib/derive';
+
+interface Props {
+ requirementIds: string[];
+ onClose: () => void;
+}
+
+export function TestCaseCreateModal({ requirementIds, onClose }: Props) {
+ const { createTestCase } = useTestCaseStore();
+ const { requirements } = useRequirementStore();
+ const { members } = useMemberStore();
+ const user = useAuthStore((s) => s.user);
+
+ const versionReqs = useMemo(
+ () => requirements.filter((r) => requirementIds.includes(r.id)),
+ [requirements, requirementIds],
+ );
+
+ const [title, setTitle] = useState('');
+ const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || '');
+ const [priority, setPriority] = useState(versionReqs[0]?.priority || 'P2');
+ const [assigneeId, setAssigneeId] = useState(user?.name || '');
+ const [description, setDescription] = useState('');
+
+ const canSubmit = title.trim() && requirementId;
+
+ const handleSubmit = () => {
+ if (!canSubmit) return;
+ createTestCase({
+ requirementId,
+ title: title.trim(),
+ description: description.trim() || undefined,
+ priority,
+ assigneeId: assigneeId || undefined,
+ createdBy: user?.name || '系统',
+ });
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
新建测试用例
+
+
+
+
+
+ setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="例如:正常排班" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/test-case/TestCaseDetailDrawer.tsx b/apps/web/components/test-case/TestCaseDetailDrawer.tsx
new file mode 100644
index 0000000..17b58fd
--- /dev/null
+++ b/apps/web/components/test-case/TestCaseDetailDrawer.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useState } from 'react';
+import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon } from 'lucide-react';
+import { TestCaseStatusBadge } from './TestCaseStatusBadge';
+import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useBugStore } from '@/stores/useBugStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { TC_ALLOWED_TRANSITIONS, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
+import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
+import type { TestCaseStatus } from '@/lib/test-case';
+
+interface Props {
+ testCaseId: string;
+ onClose: () => void;
+ onCreateBug?: (testCaseId: string) => void;
+}
+
+export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug }: Props) {
+ const { testCases, changeStatus } = useTestCaseStore();
+ const { bugs } = useBugStore();
+ const { requirements } = useRequirementStore();
+
+ const tc = testCases.find((c) => c.id === testCaseId);
+ if (!tc) return null;
+
+ const requirement = requirements.find((r) => r.id === tc.requirementId);
+ const relatedBugs = bugs.filter((b) => b.testCaseId === tc.id);
+ const nextStatuses = TC_ALLOWED_TRANSITIONS[tc.status];
+
+ const [failReason, setFailReason] = useState('');
+ const [blockReason, setBlockReason] = useState('');
+ const [showFailInput, setShowFailInput] = useState(false);
+ const [showBlockInput, setShowBlockInput] = useState(false);
+
+ const handleTransition = (to: TestCaseStatus) => {
+ if (to === 'failed') { setShowFailInput(true); return; }
+ if (to === 'blocked') { setShowBlockInput(true); return; }
+ changeStatus(tc.id, to);
+ };
+
+ const confirmFail = () => {
+ changeStatus(tc.id, 'failed', { failReason: failReason.trim() || undefined });
+ setShowFailInput(false);
+ setFailReason('');
+ };
+
+ const confirmBlock = () => {
+ changeStatus(tc.id, 'blocked', { blockReason: blockReason.trim() || undefined });
+ setShowBlockInput(false);
+ setBlockReason('');
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ {tc.caseNo}
+ {tc.title}
+
+
+
+
+
+ {/* 关联需求 */}
+ {requirement && (
+
+
关联需求
+
+
+ {requirement.code}
+ {requirement.title}
+
+
+ )}
+
+ {/* 状态 & 操作 */}
+
+
状态 & 操作
+
+
+ {tc.executedAt && 执行于 {tc.executedAt}}
+
+
+ {nextStatuses.length > 0 && !showFailInput && !showBlockInput && (
+
+
+ {nextStatuses.map((s) => (
+
+ ))}
+
+ )}
+
+ {showFailInput && (
+
+ setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="失败原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
+
+
+
+ )}
+
+ {showBlockInput && (
+
+ setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmBlock(); }} placeholder="阻塞原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
+
+
+
+ )}
+
+ {tc.failReason &&
}
+ {tc.blockReason &&
}
+
+
+ {/* 基本信息 */}
+
+
基本信息
+
+
优先级:{tc.priority}
+
负责人:{tc.assigneeId || '-'}
+
创建人:{tc.createdBy}
+
创建日期:{tc.createdAt.slice(0, 10)}
+
+
+
+ {/* 用例描述 */}
+ {tc.description && (
+
+
测试步骤 & 预期
+
{tc.description}
+
+ )}
+
+ {/* 关联 Bug + 提BUG按钮 */}
+
+
+
关联 Bug ({relatedBugs.length})
+ {tc.status === 'failed' && onCreateBug && (
+
+ )}
+
+ {relatedBugs.length === 0 ? (
+
暂无关联 Bug
+ ) : (
+
+ {relatedBugs.map((bug) => (
+
+ {bug.bugNo}
+ {bug.title}
+ {BUG_SEVERITY_LABEL[bug.severity]}
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/components/test-case/TestCaseRow.tsx b/apps/web/components/test-case/TestCaseRow.tsx
new file mode 100644
index 0000000..8741eba
--- /dev/null
+++ b/apps/web/components/test-case/TestCaseRow.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { TestCaseStatusBadge } from './TestCaseStatusBadge';
+import type { TestCase } from '@/lib/test-case';
+import type { Bug } from '@/lib/bug';
+
+interface Props {
+ testCase: TestCase;
+ bugCount: number;
+ onClick?: () => void;
+}
+
+const PRIORITY_DOT: Record = {
+ P0: 'bg-red-500',
+ P1: 'bg-orange-400',
+ P2: 'bg-blue-400',
+ P3: 'bg-zinc-300',
+};
+
+export function TestCaseRow({ testCase, bugCount, onClick }: Props) {
+ return (
+
+
+ {testCase.caseNo}
+ {testCase.title}
+
+ {bugCount > 0 && (
+ {bugCount} Bug
+ )}
+ {testCase.assigneeId || '-'}
+
+ );
+}
diff --git a/apps/web/components/test-case/TestCaseStatusBadge.tsx b/apps/web/components/test-case/TestCaseStatusBadge.tsx
new file mode 100644
index 0000000..0371915
--- /dev/null
+++ b/apps/web/components/test-case/TestCaseStatusBadge.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
+import type { TestCaseStatus } from '@/lib/test-case';
+
+export function TestCaseStatusBadge({ status }: { status: TestCaseStatus }) {
+ return (
+
+ {TEST_CASE_STATUS_LABEL[status]}
+
+ );
+}
diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx
new file mode 100644
index 0000000..c2681a9
--- /dev/null
+++ b/apps/web/components/test-case/TestCaseTab.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+import { useState, useMemo, useEffect } from 'react';
+import { Plus, ClipboardCheck, Filter } from 'lucide-react';
+import { useTestCaseStore } from '@/stores/useTestCaseStore';
+import { useBugStore } from '@/stores/useBugStore';
+import { useRequirementStore } from '@/stores/useRequirementStore';
+import { useAuthStore } from '@/stores/useAuthStore';
+import { TestCaseRow } from './TestCaseRow';
+import { TestCaseCreateModal } from './TestCaseCreateModal';
+import { TestCaseDetailDrawer } from './TestCaseDetailDrawer';
+import { BugCreateModal } from '@/components/bug/BugCreateModal';
+import { calcTestProgress, TEST_CASE_STATUS_LABEL } from '@/lib/test-case';
+import { Pagination, usePagination } from '@/components/Pagination';
+import type { TestCaseStatus } from '@/lib/test-case';
+
+interface Props {
+ versionId: string;
+ requirementIds: string[];
+}
+
+export function TestCaseTab({ versionId, requirementIds }: Props) {
+ const { testCases, fetchTestCases } = useTestCaseStore();
+ const { bugs, fetchBugs } = useBugStore();
+ const { requirements } = useRequirementStore();
+ const user = useAuthStore((s) => s.user);
+
+ useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
+ useEffect(() => { fetchBugs(); }, [fetchBugs]);
+
+ const versionCases = useMemo(
+ () => testCases.filter((c) => requirementIds.includes(c.requirementId)),
+ [testCases, requirementIds],
+ );
+
+ const stats = calcTestProgress(versionCases);
+
+ // 筛选
+ const [filterAssignee, setFilterAssignee] = useState('');
+ const [filterStatus, setFilterStatus] = useState('');
+
+ const filteredCases = useMemo(() => {
+ let result = versionCases;
+ if (filterAssignee) result = result.filter((c) => c.assigneeId === filterAssignee);
+ if (filterStatus) result = result.filter((c) => c.status === filterStatus);
+ return result;
+ }, [versionCases, filterAssignee, filterStatus]);
+
+ const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredCases, 20);
+
+ const [showCreate, setShowCreate] = useState(false);
+ const [selectedCaseId, setSelectedCaseId] = useState(null);
+ const [bugForCaseId, setBugForCaseId] = useState(null);
+
+ // 按需求分组
+ const groupedByReq = useMemo(() => {
+ const map = new Map();
+ for (const c of paged) {
+ const list = map.get(c.requirementId) || [];
+ list.push(c);
+ map.set(c.requirementId, list);
+ }
+ return map;
+ }, [paged]);
+
+ const assignees = useMemo(() => {
+ const names = new Set(versionCases.map((c) => c.assigneeId).filter(Boolean) as string[]);
+ return Array.from(names);
+ }, [versionCases]);
+
+ const hasFilter = !!(filterAssignee || filterStatus);
+
+ return (
+
+ {/* 统计栏 */}
+
+
+
+ 测试完成 {stats.completionRate}%
+
+
+
+ {stats.total} 用例 · 通过 {stats.passed} · 失败 {stats.failed} · 阻塞 {stats.blocked}
+
+
通过率 {stats.passRate}%
+
+
+
+ {/* 筛选 */}
+
+
+
+
+ {hasFilter && }
+ {filteredCases.length} 条
+
+
+ {/* 列表 */}
+ {filteredCases.length === 0 ? (
+
+
{hasFilter ? '没有匹配的用例' : '暂无测试用例'}
+ {!hasFilter &&
}
+
+ ) : (
+ Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
+ const req = requirements.find((r) => r.id === reqId);
+ const reqPassed = cases.filter((c) => c.status === 'passed').length;
+ return (
+
+
+ {req?.code}
+ {req?.title}
+ {reqPassed}/{cases.length} 通过
+
+ {cases.map((c) => (
+
b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
+ ))}
+
+ );
+ })
+ )}
+
+ {total > 20 &&
}
+
+ {showCreate &&
setShowCreate(false)} />}
+ {selectedCaseId && setSelectedCaseId(null)} onCreateBug={(id) => { setBugForCaseId(id); }} />}
+ {bugForCaseId && setBugForCaseId(null)} />}
+
+ );
+}
diff --git a/apps/web/lib/bug.ts b/apps/web/lib/bug.ts
new file mode 100644
index 0000000..f40dfee
--- /dev/null
+++ b/apps/web/lib/bug.ts
@@ -0,0 +1,75 @@
+import type { Priority } from './derive';
+
+export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected';
+export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
+
+export interface Bug {
+ id: string;
+ bugNo: string;
+ testCaseId: string;
+ title: string;
+ description: string;
+ severity: BugSeverity;
+ priority: Priority;
+ reportedBy: string;
+ assigneeId: string;
+ status: BugStatus;
+ resolvedAt?: string;
+ closedAt?: string;
+ resolution?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export const BUG_STATUS_LABEL: Record = {
+ open: '待修复',
+ fixing: '修复中',
+ fixed: '已修复',
+ verifying: '验证中',
+ closed: '已关闭',
+ rejected: '已拒绝',
+};
+
+export const BUG_STATUS_COLOR: Record = {
+ open: 'bg-red-50 text-red-600',
+ fixing: 'bg-blue-50 text-blue-600',
+ fixed: 'bg-indigo-50 text-indigo-600',
+ verifying: 'bg-purple-50 text-purple-600',
+ closed: 'bg-emerald-50 text-emerald-600',
+ rejected: 'bg-zinc-100 text-zinc-500',
+};
+
+export const BUG_SEVERITY_LABEL: Record = {
+ critical: '致命',
+ major: '严重',
+ minor: '一般',
+ trivial: '轻微',
+};
+
+export const BUG_SEVERITY_COLOR: Record = {
+ critical: 'bg-red-100 text-red-700',
+ major: 'bg-orange-50 text-orange-700',
+ minor: 'bg-yellow-50 text-yellow-700',
+ trivial: 'bg-zinc-100 text-zinc-600',
+};
+
+export const BUG_ALLOWED_TRANSITIONS: Record = {
+ open: ['fixing', 'rejected'],
+ fixing: ['fixed'],
+ fixed: ['verifying'],
+ verifying: ['closed', 'open'],
+ closed: [],
+ rejected: [],
+};
+
+export function canBugTransition(from: BugStatus, to: BugStatus): boolean {
+ return BUG_ALLOWED_TRANSITIONS[from].includes(to);
+}
+
+export function generateBugNo(existingBugs: Bug[]): string {
+ const maxNum = existingBugs.reduce((max, b) => {
+ const num = parseInt(b.bugNo.replace('BUG-', ''), 10);
+ return isNaN(num) ? max : Math.max(max, num);
+ }, 0);
+ return `BUG-${String(maxNum + 1).padStart(3, '0')}`;
+}
diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts
new file mode 100644
index 0000000..cfbad0a
--- /dev/null
+++ b/apps/web/lib/test-case.ts
@@ -0,0 +1,69 @@
+import type { Priority } from './derive';
+
+export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
+
+export interface TestCase {
+ id: string;
+ caseNo: string;
+ requirementId: string;
+ title: string;
+ description?: string;
+ priority: Priority;
+ assigneeId?: string;
+ status: TestCaseStatus;
+ executedAt?: string;
+ executedBy?: string;
+ failReason?: string;
+ blockReason?: string;
+ createdBy: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export const TEST_CASE_STATUS_LABEL: Record = {
+ pending: '待执行',
+ running: '执行中',
+ passed: '通过',
+ failed: '失败',
+ blocked: '阻塞',
+};
+
+export const TEST_CASE_STATUS_COLOR: Record = {
+ pending: 'bg-zinc-100 text-zinc-600',
+ running: 'bg-blue-50 text-blue-600',
+ passed: 'bg-emerald-50 text-emerald-600',
+ failed: 'bg-red-50 text-red-600',
+ blocked: 'bg-orange-50 text-orange-600',
+};
+
+export const TC_ALLOWED_TRANSITIONS: Record = {
+ pending: ['running'],
+ running: ['passed', 'failed', 'blocked'],
+ passed: ['running'],
+ failed: ['running'],
+ blocked: ['running'],
+};
+
+export function canTcTransition(from: TestCaseStatus, to: TestCaseStatus): boolean {
+ return TC_ALLOWED_TRANSITIONS[from].includes(to);
+}
+
+export function generateCaseNo(existingCases: TestCase[]): string {
+ const maxNum = existingCases.reduce((max, c) => {
+ const num = parseInt(c.caseNo.replace('TC-', ''), 10);
+ return isNaN(num) ? max : Math.max(max, num);
+ }, 0);
+ return `TC-${String(maxNum + 1).padStart(3, '0')}`;
+}
+
+export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } {
+ const total = cases.length;
+ if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 };
+ const passed = cases.filter((c) => c.status === 'passed').length;
+ const failed = cases.filter((c) => c.status === 'failed').length;
+ const blocked = cases.filter((c) => c.status === 'blocked').length;
+ const executed = passed + failed + blocked;
+ const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
+ const completionRate = Math.round((executed / total) * 100);
+ return { total, executed, passed, failed, blocked, passRate, completionRate };
+}
diff --git a/apps/web/stores/useBugStore.ts b/apps/web/stores/useBugStore.ts
new file mode 100644
index 0000000..6bacf80
--- /dev/null
+++ b/apps/web/stores/useBugStore.ts
@@ -0,0 +1,92 @@
+'use client';
+import { create } from 'zustand';
+import type { Bug, BugStatus } from '@/lib/bug';
+import { canBugTransition, generateBugNo } from '@/lib/bug';
+
+const STORAGE_KEY = 'ftb_bugs_v1';
+
+function saveLocal(items: Bug[]) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
+}
+
+function loadLocal(): Bug[] | null {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch {}
+ return null;
+}
+
+interface BugState {
+ bugs: Bug[];
+ fetchBugs: () => void;
+ createBug: (data: Omit) => Bug;
+ updateBug: (id: string, data: Partial) => void;
+ deleteBug: (id: string) => void;
+ changeStatus: (id: string, to: BugStatus, extra?: { resolution?: string }) => { ok: boolean; message?: string };
+ getByTestCase: (caseId: string) => Bug[];
+ getByAssignee: (assigneeId: string) => Bug[];
+}
+
+export const useBugStore = create((set, get) => ({
+ bugs: [],
+
+ fetchBugs: () => {
+ const cached = loadLocal();
+ if (cached) set({ bugs: cached });
+ },
+
+ createBug: (data) => {
+ const list = get().bugs;
+ const now = new Date().toISOString();
+ const bug: Bug = {
+ ...data,
+ id: `bug-${Date.now()}`,
+ bugNo: generateBugNo(list),
+ status: 'open',
+ createdAt: now,
+ updatedAt: now,
+ };
+ const updated = [...list, bug];
+ set({ bugs: updated });
+ saveLocal(updated);
+ return bug;
+ },
+
+ updateBug: (id, data) => {
+ const updated = get().bugs.map((b) =>
+ b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b,
+ );
+ set({ bugs: updated });
+ saveLocal(updated);
+ },
+
+ deleteBug: (id) => {
+ const updated = get().bugs.filter((b) => b.id !== id);
+ set({ bugs: updated });
+ saveLocal(updated);
+ },
+
+ changeStatus: (id, to, extra) => {
+ const bug = get().bugs.find((b) => b.id === id);
+ if (!bug) return { ok: false, message: 'Bug不存在' };
+ if (!canBugTransition(bug.status, to)) {
+ return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` };
+ }
+ const today = new Date().toISOString().slice(0, 10);
+ const patch: Partial = { status: to };
+ if (to === 'fixed') patch.resolvedAt = today;
+ if (to === 'closed') patch.closedAt = today;
+ if (extra?.resolution) patch.resolution = extra.resolution;
+ get().updateBug(id, patch);
+ return { ok: true };
+ },
+
+ getByTestCase: (caseId) => {
+ return get().bugs.filter((b) => b.testCaseId === caseId);
+ },
+
+ getByAssignee: (assigneeId) => {
+ return get().bugs.filter((b) => b.assigneeId === assigneeId);
+ },
+}));
diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts
new file mode 100644
index 0000000..ad4f5c3
--- /dev/null
+++ b/apps/web/stores/useTestCaseStore.ts
@@ -0,0 +1,95 @@
+'use client';
+import { create } from 'zustand';
+import type { TestCase, TestCaseStatus } from '@/lib/test-case';
+import { canTcTransition, generateCaseNo } from '@/lib/test-case';
+
+const STORAGE_KEY = 'ftb_test_cases_v1';
+
+function saveLocal(items: TestCase[]) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
+}
+
+function loadLocal(): TestCase[] | null {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch {}
+ return null;
+}
+
+interface TestCaseState {
+ testCases: TestCase[];
+ fetchTestCases: () => void;
+ createTestCase: (data: Omit) => TestCase;
+ updateTestCase: (id: string, data: Partial) => void;
+ deleteTestCase: (id: string) => void;
+ changeStatus: (id: string, to: TestCaseStatus, extra?: { failReason?: string; blockReason?: string }) => { ok: boolean; message?: string };
+ getByRequirement: (reqId: string) => TestCase[];
+ getByAssignee: (assigneeId: string) => TestCase[];
+}
+
+export const useTestCaseStore = create((set, get) => ({
+ testCases: [],
+
+ fetchTestCases: () => {
+ const cached = loadLocal();
+ if (cached) set({ testCases: cached });
+ },
+
+ createTestCase: (data) => {
+ const list = get().testCases;
+ const now = new Date().toISOString();
+ const tc: TestCase = {
+ ...data,
+ id: `tc-${Date.now()}`,
+ caseNo: generateCaseNo(list),
+ status: 'pending',
+ createdAt: now,
+ updatedAt: now,
+ };
+ const updated = [...list, tc];
+ set({ testCases: updated });
+ saveLocal(updated);
+ return tc;
+ },
+
+ updateTestCase: (id, data) => {
+ const updated = get().testCases.map((c) =>
+ c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c,
+ );
+ set({ testCases: updated });
+ saveLocal(updated);
+ },
+
+ deleteTestCase: (id) => {
+ const updated = get().testCases.filter((c) => c.id !== id);
+ set({ testCases: updated });
+ saveLocal(updated);
+ },
+
+ changeStatus: (id, to, extra) => {
+ const tc = get().testCases.find((c) => c.id === id);
+ if (!tc) return { ok: false, message: '用例不存在' };
+ if (!canTcTransition(tc.status, to)) {
+ return { ok: false, message: `不允许从「${tc.status}」流转到「${to}」` };
+ }
+ const today = new Date().toISOString().slice(0, 10);
+ const patch: Partial = { status: to };
+ if (to === 'running' || to === 'passed' || to === 'failed' || to === 'blocked') {
+ patch.executedAt = today;
+ }
+ if (to === 'failed' && extra?.failReason) patch.failReason = extra.failReason;
+ if (to === 'blocked' && extra?.blockReason) patch.blockReason = extra.blockReason;
+ if (to === 'running') { patch.failReason = undefined; patch.blockReason = undefined; }
+ get().updateTestCase(id, patch);
+ return { ok: true };
+ },
+
+ getByRequirement: (reqId) => {
+ return get().testCases.filter((c) => c.requirementId === reqId);
+ },
+
+ getByAssignee: (assigneeId) => {
+ return get().testCases.filter((c) => c.assigneeId === assigneeId);
+ },
+}));