diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 08a2d3f..b2f9cb1 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play } from 'lucide-react'; +import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar, Play, Trash2, Pencil } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; @@ -17,7 +17,7 @@ interface Props { } export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) { - const { tasks, changeStatus, setBlocked } = useDevTaskStore(); + const { tasks, changeStatus, setBlocked, deleteTask } = useDevTaskStore(); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); @@ -58,7 +58,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) { {task.taskNo} {task.title} - +
+ + +
{/* 滚动区域 */} diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index 4c44352..e5a0a5c 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo, useEffect } from 'react'; -import { Plus, Code2, Filter } from 'lucide-react'; +import { Plus, Code2, Filter, Trash2 } from 'lucide-react'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; @@ -22,7 +22,7 @@ interface Props { } export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props) { - const { tasks, fetchTasks } = useDevTaskStore(); + const { tasks, fetchTasks, deleteTask } = useDevTaskStore(); const { categories, fetchCategories } = useTaskCategoryStore(); const { fetchWorklogs } = useTaskWorklogStore(); const { requirements } = useRequirementStore(); @@ -44,7 +44,6 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props const [filterBlocked, setFilterBlocked] = useState(''); const [filterCategory, setFilterCategory] = useState(''); - // 应用筛选 const filteredTasks = useMemo(() => { let result = versionTasks; if (filterAssignee) result = result.filter((t) => t.assigneeId === filterAssignee); @@ -55,7 +54,6 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props return result; }, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory]); - // 分页 const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20); // 统计 @@ -67,7 +65,32 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props const [showCreate, setShowCreate] = useState(false); const [selectedTaskId, setSelectedTaskId] = useState(null); - // 按需求分组(对当前页数据) + // 批量选择 + const [selectedIds, setSelectedIds] = useState>(new Set()); + const isAllSelected = paged.length > 0 && paged.every((t) => selectedIds.has(t.id)); + + const toggleSelect = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); else next.add(id); + setSelectedIds(next); + }; + + const toggleAll = () => { + if (isAllSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(paged.map((t) => t.id))); + } + }; + + const handleBatchDelete = () => { + if (selectedIds.size === 0) return; + if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return; + selectedIds.forEach((id) => deleteTask(id)); + setSelectedIds(new Set()); + }; + + // 按需求分组 const groupedByReq = useMemo(() => { const map = new Map(); for (const t of paged) { @@ -78,7 +101,6 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props return map; }, [paged]); - // 参与人列表 const assignees = useMemo(() => { const names = new Set(versionTasks.map((t) => t.assigneeId)); return Array.from(names); @@ -108,7 +130,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props - {/* 筛选栏 */} + {/* 筛选栏 + 批量操作 */}
+ 全选当页 +
+ {Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => { + const req = requirements.find((r) => r.id === reqId); + const reqProgress = calcGroupProgress(reqTasks); + return ( +
+
+ {req?.code} + {req?.title} + {reqProgress}% +
+ {reqTasks.map((t) => ( +
+
+ toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} /> +
+
+ c.id === t.categoryId)} onClick={() => setSelectedTaskId(t.id)} /> +
+
+ ))}
- {reqTasks.map((t) => ( - c.id === t.categoryId)} onClick={() => setSelectedTaskId(t.id)} /> - ))} - - ); - }) + ); + })} + )} {/* 分页 */} diff --git a/apps/web/components/test-case/TestCaseDetailDrawer.tsx b/apps/web/components/test-case/TestCaseDetailDrawer.tsx index 17b58fd..1220cdc 100644 --- a/apps/web/components/test-case/TestCaseDetailDrawer.tsx +++ b/apps/web/components/test-case/TestCaseDetailDrawer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon } from 'lucide-react'; +import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2 } from 'lucide-react'; import { TestCaseStatusBadge } from './TestCaseStatusBadge'; import { BugStatusBadge } from '@/components/bug/BugStatusBadge'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; @@ -18,7 +18,7 @@ interface Props { } export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug }: Props) { - const { testCases, changeStatus } = useTestCaseStore(); + const { testCases, changeStatus, deleteTestCase } = useTestCaseStore(); const { bugs } = useBugStore(); const { requirements } = useRequirementStore(); @@ -60,7 +60,10 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug }: Props {tc.caseNo} {tc.title} - +
+ + +
diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx index 86da422..193f5dd 100644 --- a/apps/web/components/test-case/TestCaseTab.tsx +++ b/apps/web/components/test-case/TestCaseTab.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo, useEffect } from 'react'; -import { Plus, ClipboardCheck, Filter, CheckCircle2 } from 'lucide-react'; +import { Plus, ClipboardCheck, Filter, CheckCircle2, Trash2 } from 'lucide-react'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; @@ -21,7 +21,7 @@ interface Props { } export function TestCaseTab({ versionId, requirementIds }: Props) { - const { testCases, fetchTestCases } = useTestCaseStore(); + const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const { tasks: devTasks } = useDevTaskStore(); const { requirements } = useRequirementStore(); @@ -58,6 +58,30 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { const [selectedCaseId, setSelectedCaseId] = useState(null); const [bugForCaseId, setBugForCaseId] = useState(null); + // 批量选择 + const [selectedIds, setSelectedIds] = useState>(new Set()); + const isAllSelected = paged.length > 0 && paged.every((c) => selectedIds.has(c.id)); + + const toggleSelect = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); else next.add(id); + setSelectedIds(next); + }; + + const toggleAll = () => { + if (isAllSelected) setSelectedIds(new Set()); + else setSelectedIds(new Set(paged.map((c) => c.id))); + }; + + const handleBatchDelete = () => { + if (selectedIds.size === 0) return; + const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id)); + if (hasBug) { alert('选中的用例中有关联 Bug 的用例,无法删除。请先取消关联 Bug 的用例选择。'); return; } + if (!confirm(`确定删除选中的 ${selectedIds.size} 个测试用例?`)) return; + selectedIds.forEach((id) => deleteTestCase(id)); + setSelectedIds(new Set()); + }; + // 按需求分组(无需求的归为"通用") const groupedByReq = useMemo(() => { const map = new Map(); @@ -123,6 +147,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { {(Object.entries(TEST_CASE_STATUS_LABEL) as [TestCaseStatus, string][]).map(([k, v]) => )} {hasFilter && } + {selectedIds.size > 0 && ( + + )} {filteredCases.length} 条
@@ -133,22 +162,35 @@ export function TestCaseTab({ versionId, requirementIds }: Props) { {!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} + <> +
+ + 全选当页 +
+ {Array.from(groupedByReq.entries()).map(([reqId, cases]) => { + const req = reqId === '__none__' ? null : 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)} /> +
+
+ toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} /> +
+
+ b.testCaseId === c.id).length} onClick={() => setSelectedCaseId(c.id)} /> +
+
))}
); - }) + })} + )} {total > 20 && }