From b7d48f66aa88df3815b075dc4233d80caf6163c6 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:14:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=89=88=E6=9C=AC=E8=AF=A6=E6=83=85):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BB=BB=E5=8A=A1=E6=B5=81=E7=A8=8B=E4=B8=8E?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/components/bug/BugRow.tsx | 38 +- apps/web/components/bug/BugStatusBadge.tsx | 2 +- apps/web/components/dev-task/CategoryChip.tsx | 18 +- .../dev-task/DevTaskDetailDrawer.tsx | 117 +- apps/web/components/dev-task/DevTaskRow.tsx | 72 +- apps/web/components/dev-task/DevTaskTab.tsx | 21 +- apps/web/components/dev-task/StatusBadge.tsx | 7 +- .../test-case/TestCaseCreateModal.tsx | 45 +- .../test-case/TestCaseDetailDrawer.tsx | 122 +- apps/web/components/test-case/TestCaseRow.tsx | 96 +- .../test-case/TestCaseStatusBadge.tsx | 7 +- apps/web/components/test-case/TestCaseTab.tsx | 16 +- apps/web/lib/dev-task-workflow.test.ts | 55 +- apps/web/lib/dev-task-workflow.ts | 10 +- apps/web/lib/dev-task.ts | 15 + apps/web/lib/test-case-workflow.test.ts | 42 + apps/web/lib/test-case-workflow.ts | 6 +- apps/web/lib/test-case.ts | 20 + apps/web/lib/xiaobao-risk-trend.test.ts | 54 + apps/web/lib/xiaobao-risk-trend.ts | 6 +- apps/web/lib/xiaobao-risk.ts | 2 +- docs/decisions.md | 18 +- .../plans/2026-06-29-xiaobao-warning.md | 2394 +++++++++++++++++ docs/workflow.md | 20 +- 24 files changed, 3085 insertions(+), 118 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-29-xiaobao-warning.md diff --git a/apps/web/components/bug/BugRow.tsx b/apps/web/components/bug/BugRow.tsx index 7b68126..2ba6c4b 100644 --- a/apps/web/components/bug/BugRow.tsx +++ b/apps/web/components/bug/BugRow.tsx @@ -27,20 +27,30 @@ function BugRowImpl({ bug, testCaseNo, onClick }: Props) { const members = useMemberStore((s) => s.members); const assigneeName = resolveMemberDisplayName(bug.assigneeId, members); return ( -
- - {bug.bugNo} - {bug.title} - {BUG_SEVERITY_LABEL[bug.severity]} - - - {bug.plannedFixAt ? formatDateTimeShort(bug.plannedFixAt) : '待排期'} - - {actualHours > 0 && ( - {formatWorkHours(actualHours)} - )} - {testCaseNo && {testCaseNo}} - {assigneeName} +
+
+ + {bug.bugNo} +
+
+ {bug.title} + + + {bug.plannedFixAt ? formatDateTimeShort(bug.plannedFixAt) : '待排期'} + + {actualHours > 0 && ( + 实际 {formatWorkHours(actualHours)} + )} + +
+
+ {BUG_SEVERITY_LABEL[bug.severity]} + + {assigneeName} + {testCaseNo && {testCaseNo}} +
+
+
); } diff --git a/apps/web/components/bug/BugStatusBadge.tsx b/apps/web/components/bug/BugStatusBadge.tsx index 8676ccc..f1dbe98 100644 --- a/apps/web/components/bug/BugStatusBadge.tsx +++ b/apps/web/components/bug/BugStatusBadge.tsx @@ -5,7 +5,7 @@ import type { BugStatus } from '@/lib/bug'; export function BugStatusBadge({ status }: { status: BugStatus }) { return ( - + {BUG_STATUS_LABEL[status]} ); diff --git a/apps/web/components/dev-task/CategoryChip.tsx b/apps/web/components/dev-task/CategoryChip.tsx index 72dde82..981ade6 100644 --- a/apps/web/components/dev-task/CategoryChip.tsx +++ b/apps/web/components/dev-task/CategoryChip.tsx @@ -1,13 +1,25 @@ 'use client'; +import type { CSSProperties } from 'react'; import type { TaskCategory } from '@/lib/task-category'; -export function CategoryChip({ category }: { category?: TaskCategory }) { - if (!category) return 未分类; +export function CategoryChip({ category, widthEm }: { category?: TaskCategory; widthEm?: number }) { + const widthStyle: CSSProperties = widthEm ? { width: `${widthEm}em` } : {}; + if (!category) { + return ( + + 未分类 + + ); + } return ( s.addProgressNote); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); + const user = useAuthStore((s) => s.user); const [showTransfer, setShowTransfer] = useState(false); const [transferTo, setTransferTo] = useState(''); const [showDelayInput, setShowDelayInput] = useState(false); const [delayReason, setDelayReason] = useState(''); + const [showPlanInput, setShowPlanInput] = useState(false); + const [planStartLocal, setPlanStartLocal] = useState(''); + const [planEndLocal, setPlanEndLocal] = useState(''); const [progressNote, setProgressNote] = useState(''); const [progressBlocker, setProgressBlocker] = useState(''); const [progressHelperId, setProgressHelperId] = useState(''); @@ -61,8 +81,42 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const actual = useMemo(() => getActualHours(task), [task]); const overrun = actual > estimate && estimate > 0; const requireDelay = task.status === 'todo' && needsDelayReason(task); + const currentUserName = user?.name || ''; + const needsClaim = needsDevTaskClaim(task); + const startReady = canStartDevTask(task); + const visibleNextStatuses = nextStatuses.filter((status) => status !== 'in_progress' || startReady); + const planStartISO = localToISO(planStartLocal); + const planEndISO = localToISO(planEndLocal); + const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO); + const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0; + + const openPlanInput = () => { + setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal()); + setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal()); + setShowPlanInput(true); + }; + + const handleSavePlan = () => { + const assigneeId = task.assigneeId || currentUserName; + if (!assigneeId) { + alert('领取前需要先登录或选择负责人'); + return; + } + if (!planStartBeforeEnd || planEstimateHours <= 0 || !planStartISO || !planEndISO) return; + updateTask(task.id, { + assigneeId, + expectedStartAt: planStartISO, + expectedEndAt: planEndISO, + estimateHours: planEstimateHours, + }); + setShowPlanInput(false); + }; const handleTransition = (to: DevTaskStatus) => { + if (to === 'in_progress' && !startReady) { + openPlanInput(); + return; + } if (to === 'in_progress' && requireDelay && !showDelayInput) { setShowDelayInput(true); return; @@ -179,10 +233,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel )}
- {nextStatuses.length > 0 && !showDelayInput && ( + {visibleNextStatuses.length > 0 && !showDelayInput && (
- {nextStatuses.map((s) => ( + {visibleNextStatuses.map((s) => ( @@ -190,6 +244,61 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
)} + {task.status === 'todo' && !startReady && !showPlanInput && ( +
+ + + {needsClaim ? '领取时必须填写预计开始和预计截止' : '开始开发前需要补齐预计开始和预计截止'} + +
+ )} + + {showPlanInput && ( +
+
+ {needsClaim ? '领取并填写计划' : '填写计划'} +
+
+
+ + +
+
+ + +
+
+
+ + 执行预估:{planEstimateHours > 0 ? formatHours(planEstimateHours) : '请选择有效起止时间'} + +
+ + +
+
+
+ )} + {showDelayInput && (
@@ -332,7 +441,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
负责人 - {task.assigneeId} + {needsClaim ? '待领取' : task.assigneeId}
优先级 diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx index 85b991d..ae7d6c3 100644 --- a/apps/web/components/dev-task/DevTaskRow.tsx +++ b/apps/web/components/dev-task/DevTaskRow.tsx @@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; -import { getEstimateHours, getActualHours } from '@/lib/dev-task'; +import { getEstimateHours, getActualHours, needsDevTaskClaim } from '@/lib/dev-task'; import { formatShortTime, formatWorkHours } from '@/lib/work-hours'; import type { DevTask } from '@/lib/dev-task'; import type { TaskCategory } from '@/lib/task-category'; @@ -11,6 +11,7 @@ import type { TaskCategory } from '@/lib/task-category'; interface Props { task: DevTask; category?: TaskCategory; + categoryLabelWidthEm?: number; onClick?: () => void; } @@ -45,37 +46,64 @@ function hoursText(task: DevTask, estimate: number, actual: number): { text: str return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone }; } -export function DevTaskRow({ task, category, onClick }: Props) { +export function DevTaskRow({ task, category, categoryLabelWidthEm, onClick }: Props) { const estimate = getEstimateHours(task); const actual = getActualHours(task); const range = timeRangeText(task); const hours = hoursText(task, estimate, actual); + const needsClaim = needsDevTaskClaim(task); + const labelWidthStyle = categoryLabelWidthEm ? { width: `${categoryLabelWidthEm}em` } : undefined; return (
- - {task.taskNo} -
- {task.title} - {task.aiDraft && ( - - AI 草案 - - )} - {task.isBlocked && ( - - 阻塞 - - )} +
+ + {task.taskNo} +
+
+ {task.title} + + {range.text && ( + {range.text} + )} + {hours.text} + +
+
+ {task.isBlocked && ( + + 阻塞 + + )} + {task.aiDraft && ( + + AI 草案 + + )} + {!needsClaim && ( + 负责人:{task.assigneeId} + )} + {needsClaim ? ( + + 待领取 + + ) : ( + + )} + +
+
- - - {range.text} - {hours.text} - {task.assigneeId}
); } diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index c8fe2e8..aa2742e 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -111,6 +111,10 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props }, [paged]); const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); + const categoryLabelWidthEm = useMemo( + () => Math.max(4, ...categories.map((category) => category.name.length)) + 1, + [categories], + ); const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]); @@ -179,11 +183,16 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props const reqProgress = calcGroupProgress(reqTasks); return (
-
- selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" /> - {req?.code} - {req?.title} - {reqProgress}% +
+
+ selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" /> +
+
+ + {req?.code} + {req?.title} + {reqProgress}% +
{reqTasks.map((t) => (
@@ -191,7 +200,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
- setSelectedTaskId(t.id)} /> + setSelectedTaskId(t.id)} />
))} diff --git a/apps/web/components/dev-task/StatusBadge.tsx b/apps/web/components/dev-task/StatusBadge.tsx index fb2ea64..7159c41 100644 --- a/apps/web/components/dev-task/StatusBadge.tsx +++ b/apps/web/components/dev-task/StatusBadge.tsx @@ -3,9 +3,12 @@ import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task'; import type { DevTaskStatus } from '@/lib/dev-task'; -export function StatusBadge({ status }: { status: DevTaskStatus }) { +export function StatusBadge({ status, widthEm }: { status: DevTaskStatus; widthEm?: number }) { return ( - + {DEV_TASK_STATUS_LABEL[status]} ); diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx index d1b8497..e675ce1 100644 --- a/apps/web/components/test-case/TestCaseCreateModal.tsx +++ b/apps/web/components/test-case/TestCaseCreateModal.tsx @@ -26,6 +26,12 @@ function defaultPlannedTestLocal(): string { return isoToLocal(d.toISOString()); } +function defaultPlannedEndLocal(): string { + const d = new Date(); + d.setHours(10, 0, 0, 0); + return isoToLocal(d.toISOString()); +} + export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) { const { createTestCase } = useTestCaseStore(); const { requirements } = useRequirementStore(); @@ -49,6 +55,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos ); const [estimateHours, setEstimateHours] = useState(0.5); const [plannedTestLocal, setPlannedTestLocal] = useState(defaultPlannedTestLocal); + const [plannedEndLocal, setPlannedEndLocal] = useState(defaultPlannedEndLocal); const [assigneeId, setAssigneeId] = useState(user?.name || ''); const [description, setDescription] = useState(''); const [prototypeNotes, setPrototypeNotes] = useState(''); @@ -69,7 +76,9 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours); const plannedTestAt = localToISO(plannedTestLocal); - const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && Boolean(plannedTestAt); + const plannedEndAt = localToISO(plannedEndLocal); + const hasValidPlan = Boolean(plannedTestAt && plannedEndAt && plannedEndAt > plannedTestAt); + const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && hasValidPlan; const handleSubmit = () => { if (!canSubmit) return; @@ -92,6 +101,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos priority, estimateHours: normalizedEstimateHours, plannedTestAt, + plannedEndAt, assigneeId: assigneeId || undefined, references: references.length > 0 ? references : undefined, createdBy: user?.name || '系统', @@ -154,15 +164,32 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
-
- - +
+
+ + +
+
+ + +
+ {!hasValidPlan && plannedTestLocal && plannedEndLocal && ( +
+ 计划开始必须早于计划结束 +
+ )}