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>
This commit is contained in:
@@ -18,8 +18,12 @@ import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
|
|||||||
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
|
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
|
||||||
import { PlanTab } from '@/components/version/PlanTab';
|
import { PlanTab } from '@/components/version/PlanTab';
|
||||||
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
|
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
|
||||||
|
import { TestCaseTab } from '@/components/test-case/TestCaseTab';
|
||||||
|
import { BugTab } from '@/components/bug/BugTab';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||||||
|
|
||||||
@@ -51,6 +55,8 @@ export default function VersionDetailPage() {
|
|||||||
const { records, fetchRecords } = useOvertimeStore();
|
const { records, fetchRecords } = useOvertimeStore();
|
||||||
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
|
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
|
||||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||||
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
@@ -59,6 +65,8 @@ export default function VersionDetailPage() {
|
|||||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||||
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
|
|
||||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||||
|
|
||||||
@@ -299,6 +307,14 @@ export default function VersionDetailPage() {
|
|||||||
stageProgress['dev'] = { percent: devProgress, daysSpent: devDays };
|
stageProgress['dev'] = { percent: devProgress, daysSpent: devDays };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试阶段进度由 TestCase 完成率驱动
|
||||||
|
const versionTestCases = testCases.filter((c) => versionReqs.some((r) => r.id === c.requirementId));
|
||||||
|
if (versionTestCases.length > 0) {
|
||||||
|
const executed = versionTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
|
||||||
|
const testPercent = Math.round((executed / versionTestCases.length) * 100);
|
||||||
|
stageProgress['testing'] = { percent: testPercent, daysSpent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
return <CapsuleStages currentStage={version.currentStage} progress={version.progress} stageProgress={stageProgress} />;
|
return <CapsuleStages currentStage={version.currentStage} progress={version.progress} stageProgress={stageProgress} />;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
@@ -476,6 +492,16 @@ export default function VersionDetailPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
) : activeTab === 'testcases' ? (
|
||||||
|
(() => {
|
||||||
|
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||||
|
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
|
||||||
|
})()
|
||||||
|
) : activeTab === 'bugs' ? (
|
||||||
|
(() => {
|
||||||
|
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||||
|
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
||||||
<span className="text-[13px] text-[var(--ink-muted)]">功能开发中,敬请期待</span>
|
<span className="text-[13px] text-[var(--ink-muted)]">功能开发中,敬请期待</span>
|
||||||
|
|||||||
@@ -2,20 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||||
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours } from '@/lib/dev-task';
|
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 { PlanTask, VersionPlan } from '@/lib/version-plan';
|
||||||
import type { DevTask } from '@/lib/dev-task';
|
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 }[] = [
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
{ key: 'all', label: '全部待办', icon: ClipboardList },
|
{ key: 'all', label: '全部待办', icon: ClipboardList },
|
||||||
@@ -23,6 +27,8 @@ const TABS: { key: TabKey; label: string; icon: any }[] = [
|
|||||||
{ key: 'product', label: '产品方案', icon: FileText },
|
{ key: 'product', label: '产品方案', icon: FileText },
|
||||||
{ key: 'ui', label: 'UI设计', icon: Palette },
|
{ key: 'ui', label: 'UI设计', icon: Palette },
|
||||||
{ key: 'devTask', label: '开发任务', icon: Code2 },
|
{ key: 'devTask', label: '开发任务', icon: Code2 },
|
||||||
|
{ key: 'testCase', label: '测试用例', icon: ClipboardCheck },
|
||||||
|
{ key: 'bug', label: 'Bug', icon: BugIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function WorkspacePage() {
|
export default function WorkspacePage() {
|
||||||
@@ -32,6 +38,8 @@ export default function WorkspacePage() {
|
|||||||
const { requirements, fetchRequirements } = useRequirementStore();
|
const { requirements, fetchRequirements } = useRequirementStore();
|
||||||
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||||
@@ -41,6 +49,8 @@ export default function WorkspacePage() {
|
|||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
|
|
||||||
const userName = user?.name ?? '';
|
const userName = user?.name ?? '';
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
@@ -57,15 +67,29 @@ export default function WorkspacePage() {
|
|||||||
[devTasks, userName]
|
[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 = {
|
const counts = {
|
||||||
all: myPlans.length + myDevTasks.length,
|
all: myPlans.length + myDevTasks.length + myTestCases.length + myBugs.length,
|
||||||
research: myPlans.filter((p) => p.type === 'research').length,
|
research: myPlans.filter((p) => p.type === 'research').length,
|
||||||
product: myPlans.filter((p) => p.type === 'product').length,
|
product: myPlans.filter((p) => p.type === 'product').length,
|
||||||
ui: myPlans.filter((p) => p.type === 'ui').length,
|
ui: myPlans.filter((p) => p.type === 'ui').length,
|
||||||
devTask: myDevTasks.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 today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const toggleTask = (plan: VersionPlan, task: PlanTask) => {
|
const toggleTask = (plan: VersionPlan, task: PlanTask) => {
|
||||||
@@ -113,7 +137,7 @@ export default function WorkspacePage() {
|
|||||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">
|
<h2 className="text-[14px] font-semibold text-[var(--ink)]">
|
||||||
{TABS.find((t) => t.key === activeTab)?.label}
|
{TABS.find((t) => t.key === activeTab)?.label}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{activeTab === 'devTask' ? myDevTasks.length : filtered.length} 项</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)] space-y-3">
|
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)] space-y-3">
|
||||||
@@ -150,6 +174,49 @@ export default function WorkspacePage() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
) : 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 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
<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>
|
<p className="text-[13px] text-[var(--ink-muted)]">暂无待办事项</p>
|
||||||
|
|||||||
117
apps/web/components/bug/BugCreateModal.tsx
Normal file
117
apps/web/components/bug/BugCreateModal.tsx
Normal file
@@ -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<BugSeverity>('major');
|
||||||
|
const [priority, setPriority] = useState<Priority>(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 (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-[14px] font-semibold text-[var(--ink)]">提交 Bug</h3>
|
||||||
|
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 只读关联信息 */}
|
||||||
|
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 mb-4 space-y-1 text-[12px]">
|
||||||
|
<div><span className="text-[var(--ink-muted)]">关联用例:</span><span className="text-[var(--ink)]">{tc?.caseNo} {tc?.title}</span></div>
|
||||||
|
{requirement && <div><span className="text-[var(--ink-muted)]">关联需求:</span><span className="text-[var(--ink)]">{requirement.code} {requirement.title}</span></div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">Bug 标题 *</label>
|
||||||
|
<input value={title} onChange={(e) => 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="简要描述问题" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">Bug 描述 *</label>
|
||||||
|
<textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="复现步骤、实际结果、预期结果" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">严重程度</label>
|
||||||
|
<select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)} 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">
|
||||||
|
<option value="critical">致命</option>
|
||||||
|
<option value="major">严重</option>
|
||||||
|
<option value="minor">一般</option>
|
||||||
|
<option value="trivial">轻微</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">优先级</label>
|
||||||
|
<select value={priority} onChange={(e) => setPriority(e.target.value as Priority)} 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">
|
||||||
|
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">修复人 *</label>
|
||||||
|
<select value={assigneeId} onChange={(e) => setAssigneeId(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">
|
||||||
|
<option value="">选择修复人</option>
|
||||||
|
{members.map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
|
||||||
|
<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={handleSubmit} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-red-500 text-white hover:bg-red-600 disabled:opacity-50">提交 Bug</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/web/components/bug/BugDetailDrawer.tsx
Normal file
133
apps/web/components/bug/BugDetailDrawer.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-[var(--shadow-lg)] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{bug.bugNo}</span>
|
||||||
|
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{bug.title}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{/* 关联链路 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3 space-y-2">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联链路</div>
|
||||||
|
{tc && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
|
||||||
|
<span className="text-[13px] text-[var(--ink)]">{tc.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requirement && (
|
||||||
|
<div className="flex items-center gap-2 pl-5">
|
||||||
|
<ChevronRight className="h-3 w-3 text-[var(--ink-muted)]" />
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
|
||||||
|
<span className="text-[12px] text-[var(--ink-soft)]">{requirement.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态 & 操作 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BugStatusBadge status={bug.status} />
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextStatuses.length > 0 && !showResolutionInput && (
|
||||||
|
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
{nextStatuses.map((s) => (
|
||||||
|
<button key={s} onClick={() => handleTransition(s)} className={`h-8 px-4 rounded-lg text-[12px] font-medium transition-colors ${s === 'closed' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : s === 'rejected' ? 'bg-zinc-400 text-white hover:bg-zinc-500' : 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]'}`}>
|
||||||
|
{BUG_STATUS_LABEL[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showResolutionInput && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<input value={resolution} onChange={(e) => 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 />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={confirmFix} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-[var(--accent)] text-white">确认已修复</button>
|
||||||
|
<button onClick={() => setShowResolutionInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||||
|
<div><span className="text-[var(--ink-muted)]">提交人:</span><span className="text-[var(--ink)]">{bug.reportedBy}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">修复人:</span><span className="text-[var(--ink)] font-medium">{bug.assigneeId}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">提交:</span><span className="text-[var(--ink)]">{bug.createdAt.slice(0, 10)}</span></div>
|
||||||
|
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]">修复:</span><span className="text-[var(--ink)]">{bug.resolvedAt}</span></div>}
|
||||||
|
{bug.closedAt && <div><span className="text-[var(--ink-muted)]">关闭:</span><span className="text-emerald-600">{bug.closedAt}</span></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">Bug 描述</div>
|
||||||
|
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed whitespace-pre-wrap">{bug.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 修复说明 */}
|
||||||
|
{bug.resolution && (
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||||
|
<div className="text-[10px] text-emerald-700 uppercase tracking-wide mb-2">修复说明</div>
|
||||||
|
<p className="text-[12px] text-emerald-800 leading-relaxed whitespace-pre-wrap">{bug.resolution}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/components/bug/BugRow.tsx
Normal file
32
apps/web/components/bug/BugRow.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
P0: 'bg-red-500',
|
||||||
|
P1: 'bg-orange-400',
|
||||||
|
P2: 'bg-blue-400',
|
||||||
|
P3: 'bg-zinc-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BugRow({ bug, testCaseNo, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
||||||
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{bug.bugNo}</span>
|
||||||
|
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{bug.title}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||||
|
<BugStatusBadge status={bug.status} />
|
||||||
|
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right">{testCaseNo}</span>}
|
||||||
|
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{bug.assigneeId}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/components/bug/BugStatusBadge.tsx
Normal file
12
apps/web/components/bug/BugStatusBadge.tsx
Normal file
@@ -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 (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${BUG_STATUS_COLOR[status]}`}>
|
||||||
|
{BUG_STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/web/components/bug/BugTab.tsx
Normal file
128
apps/web/components/bug/BugTab.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
const assignees = useMemo(() => {
|
||||||
|
const names = new Set(versionBugs.map((b) => b.assigneeId));
|
||||||
|
return Array.from(names);
|
||||||
|
}, [versionBugs]);
|
||||||
|
|
||||||
|
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 统计栏 */}
|
||||||
|
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BugIcon className="h-4 w-4 text-red-500" />
|
||||||
|
<span className="text-[13px] font-medium text-[var(--ink)]">Bug {versionBugs.length} 个</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums">
|
||||||
|
待修复 {openCount} · 修复中 {fixingCount} · 已修复 {fixedCount} · 已关闭 {closedCount}
|
||||||
|
</span>
|
||||||
|
{criticalCount > 0 && (
|
||||||
|
<span className="text-[11px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-medium">{criticalCount} 致命</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)]">解决率 {resolveRate}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选 */}
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
|
<option value="">全部修复人</option>
|
||||||
|
{user?.name && <option value={user.name}>我的Bug</option>}
|
||||||
|
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
{(Object.entries(BUG_STATUS_LABEL) as [BugStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filterSeverity} onChange={(e) => { setFilterSeverity(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
|
<option value="">全部严重程度</option>
|
||||||
|
{(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterSeverity(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
||||||
|
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredBugs.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
{filteredBugs.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)]">{hasFilter ? '没有匹配的 Bug' : '暂无 Bug,很好'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
|
{paged.map((bug) => {
|
||||||
|
const tc = testCases.find((c) => c.id === bug.testCaseId);
|
||||||
|
return <BugRow key={bug.id} bug={bug} testCaseNo={tc?.caseNo} onClick={() => setSelectedBugId(bug.id)} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
|
{selectedBugId && <BugDetailDrawer bugId={selectedBugId} onClose={() => setSelectedBugId(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/components/test-case/TestCaseCreateModal.tsx
Normal file
93
apps/web/components/test-case/TestCaseCreateModal.tsx
Normal file
@@ -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<Priority>(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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-[14px] font-semibold text-[var(--ink)]">新建测试用例</h3>
|
||||||
|
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">用例标题 *</label>
|
||||||
|
<input value={title} onChange={(e) => 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="例如:正常排班" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">所属需求 *</label>
|
||||||
|
<select value={requirementId} onChange={(e) => { setRequirementId(e.target.value); const r = versionReqs.find((x) => x.id === e.target.value); if (r) setPriority(r.priority); }} 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">
|
||||||
|
{versionReqs.map((r) => <option key={r.id} value={r.id}>{r.code} {r.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">优先级</label>
|
||||||
|
<select value={priority} onChange={(e) => setPriority(e.target.value as Priority)} 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">
|
||||||
|
{(['P0','P1','P2','P3'] as Priority[]).map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">测试负责人</label>
|
||||||
|
<select value={assigneeId} onChange={(e) => setAssigneeId(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">
|
||||||
|
<option value="">选择负责人</option>
|
||||||
|
{members.map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">测试步骤 & 预期结果</label>
|
||||||
|
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="1. 操作步骤... 2. 预期结果..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
|
||||||
|
<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={handleSubmit} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/components/test-case/TestCaseDetailDrawer.tsx
Normal file
166
apps/web/components/test-case/TestCaseDetailDrawer.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-[var(--shadow-lg)] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] bg-[var(--bg-card)] shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{tc.caseNo}</span>
|
||||||
|
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{/* 关联需求 */}
|
||||||
|
{requirement && (
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-1.5">关联需求</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-[var(--accent)]" />
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{requirement.code}</span>
|
||||||
|
<span className="text-[13px] text-[var(--ink)]">{requirement.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 状态 & 操作 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">状态 & 操作</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TestCaseStatusBadge status={tc.status} />
|
||||||
|
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]">执行于 {tc.executedAt}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nextStatuses.length > 0 && !showFailInput && !showBlockInput && (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
{nextStatuses.map((s) => (
|
||||||
|
<button key={s} onClick={() => handleTransition(s)} className={`h-8 px-4 rounded-lg text-[12px] font-medium transition-colors ${s === 'passed' ? 'bg-emerald-500 text-white hover:bg-emerald-600' : s === 'failed' ? 'bg-red-500 text-white hover:bg-red-600' : 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]'}`}>
|
||||||
|
{TEST_CASE_STATUS_LABEL[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFailInput && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<input value={failReason} onChange={(e) => 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 />
|
||||||
|
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white">确认失败</button>
|
||||||
|
<button onClick={() => setShowFailInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBlockInput && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<input value={blockReason} onChange={(e) => 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 />
|
||||||
|
<button onClick={confirmBlock} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-orange-500 text-white">确认阻塞</button>
|
||||||
|
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tc.failReason && <div className="text-[12px] text-red-600 bg-red-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />失败原因:{tc.failReason}</div>}
|
||||||
|
{tc.blockReason && <div className="text-[12px] text-orange-600 bg-orange-50 rounded-lg px-3 py-2"><AlertTriangle className="h-3 w-3 inline mr-1" />阻塞原因:{tc.blockReason}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3">基本信息</div>
|
||||||
|
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
|
||||||
|
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)] font-medium">{tc.priority}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">负责人:</span><span className="text-[var(--ink)] font-medium">{tc.assigneeId || '-'}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">创建人:</span><span className="text-[var(--ink)]">{tc.createdBy}</span></div>
|
||||||
|
<div><span className="text-[var(--ink-muted)]">创建日期:</span><span className="text-[var(--ink)]">{tc.createdAt.slice(0, 10)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用例描述 */}
|
||||||
|
{tc.description && (
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">测试步骤 & 预期</div>
|
||||||
|
<p className="text-[12px] text-[var(--ink-soft)] leading-relaxed whitespace-pre-wrap">{tc.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 关联 Bug + 提BUG按钮 */}
|
||||||
|
<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="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联 Bug ({relatedBugs.length})</div>
|
||||||
|
{tc.status === 'failed' && onCreateBug && (
|
||||||
|
<button onClick={() => onCreateBug(tc.id)} className="flex items-center gap-1 h-7 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||||
|
<BugIcon className="h-3 w-3" />提 BUG
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{relatedBugs.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-[var(--ink-muted)]">暂无关联 Bug</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{relatedBugs.map((bug) => (
|
||||||
|
<div key={bug.id} className="flex items-center gap-2 text-[12px] px-2.5 py-1.5 rounded-lg bg-[var(--bg-subtle)]">
|
||||||
|
<span className="font-mono text-[var(--ink-muted)]">{bug.bugNo}</span>
|
||||||
|
<span className="text-[var(--ink)] flex-1 truncate">{bug.title}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${BUG_SEVERITY_COLOR[bug.severity]}`}>{BUG_SEVERITY_LABEL[bug.severity]}</span>
|
||||||
|
<BugStatusBadge status={bug.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/components/test-case/TestCaseRow.tsx
Normal file
33
apps/web/components/test-case/TestCaseRow.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
P0: 'bg-red-500',
|
||||||
|
P1: 'bg-orange-400',
|
||||||
|
P2: 'bg-blue-400',
|
||||||
|
P3: 'bg-zinc-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TestCaseRow({ testCase, bugCount, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
|
||||||
|
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[testCase.priority] || 'bg-zinc-300'}`} />
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-14 shrink-0">{testCase.caseNo}</span>
|
||||||
|
<span className="text-[13px] text-[var(--ink)] flex-1 truncate">{testCase.title}</span>
|
||||||
|
<TestCaseStatusBadge status={testCase.status} />
|
||||||
|
{bugCount > 0 && (
|
||||||
|
<span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">{bugCount} Bug</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{testCase.assigneeId || '-'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/components/test-case/TestCaseStatusBadge.tsx
Normal file
12
apps/web/components/test-case/TestCaseStatusBadge.tsx
Normal file
@@ -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 (
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${TEST_CASE_STATUS_COLOR[status]}`}>
|
||||||
|
{TEST_CASE_STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/web/components/test-case/TestCaseTab.tsx
Normal file
141
apps/web/components/test-case/TestCaseTab.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [bugForCaseId, setBugForCaseId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 按需求分组
|
||||||
|
const groupedByReq = useMemo(() => {
|
||||||
|
const map = new Map<string, typeof paged>();
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 统计栏 */}
|
||||||
|
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardCheck className="h-4 w-4 text-[var(--accent)]" />
|
||||||
|
<span className="text-[13px] font-medium text-[var(--ink)]">测试完成 {stats.completionRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 flex-1 rounded-full bg-[var(--bg)] overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${stats.completionRate}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums">
|
||||||
|
{stats.total} 用例 · 通过 {stats.passed} · 失败 {stats.failed} · 阻塞 {stats.blocked}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)]">通过率 {stats.passRate}%</span>
|
||||||
|
<button onClick={() => setShowCreate(true)} className="ml-auto flex items-center gap-1 h-7 px-3 rounded-md text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
||||||
|
<Plus className="h-3 w-3" />新建用例
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选 */}
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<Filter className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
|
<option value="">全部负责人</option>
|
||||||
|
{user?.name && <option value={user.name}>我的用例</option>}
|
||||||
|
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
{(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setPage(1); }} className="text-[11px] text-[var(--accent)] hover:underline">清除</button>}
|
||||||
|
<span className="ml-auto text-[11px] text-[var(--ink-muted)]">{filteredCases.length} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表 */}
|
||||||
|
{filteredCases.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)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
|
||||||
|
{!hasFilter && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个用例</button>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-subtle)] border-b border-[var(--line)]">
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req?.code}</span>
|
||||||
|
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title}</span>
|
||||||
|
<span className="text-[11px] text-[var(--ink-muted)]">{reqPassed}/{cases.length} 通过</span>
|
||||||
|
</div>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<TestCaseRow key={c.id} testCase={c} bugCount={bugs.filter((b) => b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
|
{showCreate && <TestCaseCreateModal requirementIds={requirementIds} onClose={() => setShowCreate(false)} />}
|
||||||
|
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { setBugForCaseId(id); }} />}
|
||||||
|
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/web/lib/bug.ts
Normal file
75
apps/web/lib/bug.ts
Normal file
@@ -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<BugStatus, string> = {
|
||||||
|
open: '待修复',
|
||||||
|
fixing: '修复中',
|
||||||
|
fixed: '已修复',
|
||||||
|
verifying: '验证中',
|
||||||
|
closed: '已关闭',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUG_STATUS_COLOR: Record<BugStatus, string> = {
|
||||||
|
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<BugSeverity, string> = {
|
||||||
|
critical: '致命',
|
||||||
|
major: '严重',
|
||||||
|
minor: '一般',
|
||||||
|
trivial: '轻微',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUG_SEVERITY_COLOR: Record<BugSeverity, string> = {
|
||||||
|
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<BugStatus, BugStatus[]> = {
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
69
apps/web/lib/test-case.ts
Normal file
69
apps/web/lib/test-case.ts
Normal file
@@ -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<TestCaseStatus, string> = {
|
||||||
|
pending: '待执行',
|
||||||
|
running: '执行中',
|
||||||
|
passed: '通过',
|
||||||
|
failed: '失败',
|
||||||
|
blocked: '阻塞',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_CASE_STATUS_COLOR: Record<TestCaseStatus, string> = {
|
||||||
|
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<TestCaseStatus, TestCaseStatus[]> = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
92
apps/web/stores/useBugStore.ts
Normal file
92
apps/web/stores/useBugStore.ts
Normal file
@@ -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, 'id' | 'bugNo' | 'createdAt' | 'updatedAt' | 'status'>) => Bug;
|
||||||
|
updateBug: (id: string, data: Partial<Bug>) => 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<BugState>((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<Bug> = { 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
95
apps/web/stores/useTestCaseStore.ts
Normal file
95
apps/web/stores/useTestCaseStore.ts
Normal file
@@ -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, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status'>) => TestCase;
|
||||||
|
updateTestCase: (id: string, data: Partial<TestCase>) => 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<TestCaseState>((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<TestCase> = { 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user