diff --git a/apps/web/components/WorkDateTimePicker.tsx b/apps/web/components/WorkDateTimePicker.tsx new file mode 100644 index 0000000..d67ad11 --- /dev/null +++ b/apps/web/components/WorkDateTimePicker.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { AlertTriangle, CalendarDays, ChevronLeft, ChevronRight, Clock3, X } from 'lucide-react'; +import { getChinaWorkdayInfo, getDateKey } from '@/lib/china-workday-calendar'; + +interface WorkDateTimePickerProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + popoverAlign?: 'left' | 'right'; + defaultHour?: number; +} + +const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']; +const HOURS = Array.from({ length: 24 }, (_, index) => index); +const MINUTES = [0, 15, 30, 45]; + +export function WorkDateTimePicker({ + value, + onChange, + placeholder = '选择日期时间', + className = '', + disabled = false, + popoverAlign = 'left', + defaultHour = 9, +}: WorkDateTimePickerProps) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const parts = parseLocalDateTime(value, defaultHour); + const selectedDate = parts.dateKey; + const selectedInfo = selectedDate ? getChinaWorkdayInfo(selectedDate) : null; + const [viewDate, setViewDate] = useState(() => getInitialViewDate(value)); + + useEffect(() => { + if (!open) return; + const handler = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); + }; + const keyHandler = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', handler); + document.addEventListener('keydown', keyHandler); + return () => { + document.removeEventListener('mousedown', handler); + document.removeEventListener('keydown', keyHandler); + }; + }, [open]); + + useEffect(() => { + if (value) setViewDate(getInitialViewDate(value)); + }, [value]); + + const days = useMemo(() => buildCalendarDays(viewDate), [viewDate]); + const displayText = value ? value.replace('T', ' ') : ''; + const hasRestDayWarning = Boolean(selectedInfo && !selectedInfo.isWorkday); + + const updateDate = (dateKey: string) => { + onChange(makeLocalDateTime(dateKey, parts.hour, parts.minute)); + }; + + const updateTime = (nextHour: number, nextMinute: number) => { + const dateKey = selectedDate || getDateKey(new Date()); + onChange(makeLocalDateTime(dateKey, nextHour, nextMinute)); + }; + + const clearValue = (event: React.MouseEvent) => { + event.stopPropagation(); + onChange(''); + }; + + return ( +
+ + + {open && ( +
+
+ + + {viewDate.getFullYear()}年{viewDate.getMonth() + 1}月 + + +
+ +
+ {WEEKDAYS.map((day) => {day})} +
+ +
+ {days.map((day) => { + const info = getChinaWorkdayInfo(day.dateKey); + const isSelected = day.dateKey === selectedDate; + const isToday = day.dateKey === getDateKey(new Date()); + const tone = getDayTone(info.type, day.inMonth, isSelected); + return ( + + ); + })} +
+ +
+ + + +
+ + {selectedInfo && ( +
+ {selectedInfo.isWorkday + ? selectedInfo.type === 'makeup_workday' ? `${selectedInfo.label},按工作日处理` : '所选日期为工作日' + : `所选日期为${selectedInfo.label},仍可保存`} +
+ )} +
+ )} +
+ ); +} + +function parseLocalDateTime(value: string, defaultHour: number): { dateKey: string; hour: number; minute: number } { + const matched = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/); + if (!matched) return { dateKey: '', hour: defaultHour, minute: 0 }; + return { + dateKey: matched[1], + hour: Number(matched[2]), + minute: normalizeMinute(Number(matched[3])), + }; +} + +function normalizeMinute(minute: number): number { + if (MINUTES.includes(minute)) return minute; + return MINUTES.reduce((best, current) => Math.abs(current - minute) < Math.abs(best - minute) ? current : best, 0); +} + +function makeLocalDateTime(dateKey: string, hour: number, minute: number): string { + return `${dateKey}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +function getInitialViewDate(value: string): Date { + const dateKey = getDateKey(value || new Date()); + const [year, month] = dateKey.split('-').map(Number); + return new Date(year, month - 1, 1); +} + +function addMonths(date: Date, count: number): Date { + return new Date(date.getFullYear(), date.getMonth() + count, 1); +} + +function buildCalendarDays(viewDate: Date): Array<{ dateKey: string; day: number; inMonth: boolean }> { + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + const first = new Date(year, month, 1); + const mondayOffset = (first.getDay() + 6) % 7; + const cursor = new Date(year, month, 1 - mondayOffset); + const days = []; + + for (let index = 0; index < 42; index += 1) { + days.push({ + dateKey: getDateKey(cursor), + day: cursor.getDate(), + inMonth: cursor.getMonth() === month, + }); + cursor.setDate(cursor.getDate() + 1); + } + + return days; +} + +function getDayTone(type: string, inMonth: boolean, selected: boolean): string { + if (selected) return 'bg-[var(--accent)] text-white shadow-sm'; + if (!inMonth) return 'text-[var(--ink-muted)] opacity-35 hover:bg-[var(--bg-subtle)]'; + if (type === 'holiday') return 'bg-red-50 text-red-600 hover:bg-red-100'; + if (type === 'makeup_workday') return 'bg-blue-50 text-blue-600 hover:bg-blue-100'; + if (type === 'weekend') return 'text-amber-600 hover:bg-amber-50'; + return 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'; +} diff --git a/apps/web/components/bug/BugCreateModal.tsx b/apps/web/components/bug/BugCreateModal.tsx index 232db0e..24ae19f 100644 --- a/apps/web/components/bug/BugCreateModal.tsx +++ b/apps/web/components/bug/BugCreateModal.tsx @@ -9,14 +9,22 @@ import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { resolveMemberDisplayName } from '@/lib/member-system'; +import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import type { Priority } from '@/lib/derive'; import type { BugSeverity } from '@/lib/bug'; +import { isoToLocal, localToISO } from '@/lib/work-hours'; interface Props { testCaseId: string; onClose: () => void; } +function defaultPlannedFixLocal(): string { + const d = new Date(); + d.setHours(18, 0, 0, 0); + return isoToLocal(d.toISOString()); +} + export function BugCreateModal({ testCaseId, onClose }: Props) { const { createBug } = useBugStore(); const { testCases } = useTestCaseStore(); @@ -42,10 +50,12 @@ export function BugCreateModal({ testCaseId, onClose }: Props) { const [severity, setSeverity] = useState('major'); const [priority, setPriority] = useState(tc?.priority || 'P1'); const [assigneeId, setAssigneeId] = useState(defaultAssignee); + const [plannedFixLocal, setPlannedFixLocal] = useState(defaultPlannedFixLocal); const [images, setImages] = useState([]); const fileRef = useRef(null); - const canSubmit = title.trim() && description.trim() && assigneeId; + const plannedFixAt = localToISO(plannedFixLocal); + const canSubmit = title.trim() && description.trim() && assigneeId && Boolean(plannedFixAt); const handleFiles = (files: FileList | null) => { if (!files) return; @@ -72,6 +82,7 @@ export function BugCreateModal({ testCaseId, onClose }: Props) { priority, reportedBy: operator, assigneeId, + plannedFixAt, images: images.length > 0 ? images : undefined, }, operator); onClose(); @@ -154,6 +165,15 @@ export function BugCreateModal({ testCaseId, onClose }: Props) { +
+ + +
diff --git a/apps/web/components/bug/BugDetailDrawer.tsx b/apps/web/components/bug/BugDetailDrawer.tsx index 03c7e4a..6f88951 100644 --- a/apps/web/components/bug/BugDetailDrawer.tsx +++ b/apps/web/components/bug/BugDetailDrawer.tsx @@ -166,6 +166,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
基本信息
+
计划修复:{bug.plannedFixAt ? formatDateTime(bug.plannedFixAt) : '待排期'}
提交人:{reporterName}
修复人:{assigneeName}
提交:{formatDateTime(bug.createdAt)}
diff --git a/apps/web/components/bug/BugRow.tsx b/apps/web/components/bug/BugRow.tsx index e9d2ee1..7b68126 100644 --- a/apps/web/components/bug/BugRow.tsx +++ b/apps/web/components/bug/BugRow.tsx @@ -4,6 +4,7 @@ import { memo } from 'react'; import { BugStatusBadge } from './BugStatusBadge'; import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug'; import { formatWorkHours } from '@/lib/work-hours'; +import { formatDateTimeShort } from '@/lib/format'; import { resolveMemberDisplayName } from '@/lib/member-system'; import { useMemberStore } from '@/stores/useMemberStore'; import type { Bug } from '@/lib/bug'; @@ -32,6 +33,9 @@ function BugRowImpl({ bug, testCaseNo, onClick }: Props) { {bug.title} {BUG_SEVERITY_LABEL[bug.severity]} + + {bug.plannedFixAt ? formatDateTimeShort(bug.plannedFixAt) : '待排期'} + {actualHours > 0 && ( {formatWorkHours(actualHours)} )} diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 98bdc4a..2bf8dd2 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -7,6 +7,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours'; import type { Priority } from '@/lib/derive'; @@ -134,12 +135,12 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

新建开发任务

-
+
setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="例如:排班界面开发" /> @@ -168,11 +169,24 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
- setExpectedStartLocal(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" /> +
- setExpectedEndLocal(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" /> +
diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index ab1eb1c..ddfe52d 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -173,7 +173,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel )} {requireDelay && ( - + 超期未开始 )} @@ -194,7 +194,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
- 已超过预计开始时间,请说明延后原因 + 已超过预计截止时间,请说明延后原因
setDelayReason(e.target.value)} placeholder="延后原因(必填)" className="h-8 w-full rounded-md border border-orange-200 bg-white px-2 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx index 1910f45..d1b8497 100644 --- a/apps/web/components/test-case/TestCaseCreateModal.tsx +++ b/apps/web/components/test-case/TestCaseCreateModal.tsx @@ -7,9 +7,11 @@ import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import type { Priority } from '@/lib/derive'; import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category'; import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy'; +import { isoToLocal, localToISO } from '@/lib/work-hours'; interface Props { versionId: string; @@ -18,6 +20,12 @@ interface Props { onClose: () => void; } +function defaultPlannedTestLocal(): string { + const d = new Date(); + d.setHours(9, 0, 0, 0); + return isoToLocal(d.toISOString()); +} + export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) { const { createTestCase } = useTestCaseStore(); const { requirements } = useRequirementStore(); @@ -40,6 +48,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos [categories, categoryId, testCategories], ); const [estimateHours, setEstimateHours] = useState(0.5); + const [plannedTestLocal, setPlannedTestLocal] = useState(defaultPlannedTestLocal); const [assigneeId, setAssigneeId] = useState(user?.name || ''); const [description, setDescription] = useState(''); const [prototypeNotes, setPrototypeNotes] = useState(''); @@ -59,7 +68,8 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos }; const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours); - const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0; + const plannedTestAt = localToISO(plannedTestLocal); + const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && Boolean(plannedTestAt); const handleSubmit = () => { if (!canSubmit) return; @@ -81,6 +91,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos categoryId, priority, estimateHours: normalizedEstimateHours, + plannedTestAt, assigneeId: assigneeId || undefined, references: references.length > 0 ? references : undefined, createdBy: user?.name || '系统', @@ -143,6 +154,15 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
+
+ + +