diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 785a600..8993303 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -19,7 +19,9 @@ import { VersionRequirementsTab } from '@/components/version/VersionRequirements import { PlanTab } from '@/components/version/PlanTab'; import { DevTaskTab } from '@/components/dev-task/DevTaskTab'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; +import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; const PRIORITY_STYLE: Record = { P0: 'bg-red-500/10 text-red-600', @@ -48,6 +50,7 @@ export default function VersionDetailPage() { const { requirements, fetchRequirements, updateRequirement } = useRequirementStore(); const { records, fetchRecords } = useOvertimeStore(); const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore(); + const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore(); const user = useAuthStore((s) => s.user); const [activeTab, setActiveTab] = useState('overview'); @@ -55,6 +58,7 @@ export default function VersionDetailPage() { useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRecords(); }, [fetchRecords]); useEffect(() => { fetchPlans(); }, [fetchPlans]); + useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); @@ -177,11 +181,13 @@ export default function VersionDetailPage() { const reasonRanking = Object.entries(reasonMap).sort((a, b) => b[1] - a[1]); const reasonTotal = reasonRanking.reduce((s, [, v]) => s + v, 0) || 1; - // Mock 数据占位 - const mockTaskCount = Math.max(versionReqs.length * 2, 3); + // DevTask 真实统计 + const versionDevTasks = devTasks.filter((t) => versionReqs.some((r) => r.id === t.requirementId)); + const devTaskTodo = versionDevTasks.filter((t) => t.status === 'todo').length; + const devTaskInProgress = versionDevTasks.filter((t) => t.status === 'in_progress').length; + const devTaskBlocked = versionDevTasks.filter((t) => t.isBlocked).length; const mockBugTotal = Math.max(Math.floor(versionReqs.length * 1.5), 2); const mockBugOpen = Math.max(Math.floor(mockBugTotal * 0.3), 1); - const mockTaskPending = Math.max(Math.floor(mockTaskCount * 0.4), 1); return (
@@ -213,9 +219,9 @@ export default function VersionDetailPage() {
- - - + + + 0} />
@@ -285,6 +291,14 @@ export default function VersionDetailPage() { if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), daysSpent: calcDays(productPlans) }; if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), daysSpent: calcDays(uiPlans) }; + // 开发阶段进度由 DevTask 驱动 + if (versionDevTasks.length > 0) { + const devProgress = calcDevTaskProgress(versionDevTasks); + const devStart = versionDevTasks.reduce((min, t) => t.startDate && t.startDate < min ? t.startDate : min, today); + const devDays = devStart <= today ? Math.max(1, Math.ceil((new Date(today).getTime() - new Date(devStart).getTime()) / (1000 * 60 * 60 * 24))) : 0; + stageProgress['dev'] = { percent: devProgress, daysSpent: devDays }; + } + return ; })()} @@ -458,6 +472,7 @@ export default function VersionDetailPage() { r.id)} + versionDeadline={version.expectedReleaseDate ?? undefined} /> ); })() diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 32a32a6..b567585 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo } from 'react'; -import { X } from 'lucide-react'; +import { X, AlertTriangle } from 'lucide-react'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -12,11 +12,12 @@ import type { Priority } from '@/lib/derive'; interface Props { versionId: string; requirementIds: string[]; + versionDeadline?: string; onClose: () => void; onCreated?: () => void; } -export function DevTaskCreateModal({ versionId, requirementIds, onClose, onCreated }: Props) { +export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) { const { createTask, tasks } = useDevTaskStore(); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); @@ -35,16 +36,18 @@ export function DevTaskCreateModal({ versionId, requirementIds, onClose, onCreat const [title, setTitle] = useState(''); const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || ''); const [categoryId, setCategoryId] = useState(categories[0]?.id || ''); - const [assigneeId, setAssigneeId] = useState(''); + const [assigneeId, setAssigneeId] = useState(user?.name || ''); const [priority, setPriority] = useState('P2'); const [estimateHours, setEstimateHours] = useState(8); const [dueDate, setDueDate] = useState(''); const [predecessorIds, setPredecessorIds] = useState([]); const [description, setDescription] = useState(''); + const [overdueReason, setOverdueReason] = useState(''); const selectedReq = versionReqs.find((r) => r.id === requirementId); const effectivePriority = selectedReq?.priority || priority; - const canSubmit = title.trim() && requirementId && categoryId && assigneeId && estimateHours > 0; + const isOverdue = !!(dueDate && versionDeadline && dueDate > versionDeadline); + const canSubmit = title.trim() && requirementId && categoryId && assigneeId && estimateHours > 0 && (!isOverdue || overdueReason.trim()); const handleSubmit = () => { if (!canSubmit) return; @@ -65,6 +68,7 @@ export function DevTaskCreateModal({ versionId, requirementIds, onClose, onCreat blockedById: undefined, predecessorIds: predecessorIds.length > 0 ? predecessorIds : undefined, riskLevel: undefined, + overdueReason: isOverdue ? overdueReason.trim() : undefined, createdBy: user?.name || '系统', }); onCreated?.(); @@ -118,8 +122,20 @@ export function DevTaskCreateModal({ versionId, requirementIds, onClose, onCreat
setDueDate(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" /> + {versionDeadline && ( +

版本截止:{versionDeadline}

+ )}
+ {isOverdue && ( +
+
+ + 任务截止日期超出版本截止日期({versionDeadline}),请说明原因 +
+ setOverdueReason(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" /> +
+ )} {versionTasks.length > 0 && (
diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 375663c..ace120d 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -1,32 +1,39 @@ 'use client'; import { useState } from 'react'; -import { X, AlertTriangle, Link2 } from 'lucide-react'; +import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Calendar } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; import { WorklogPanel } from './WorklogPanel'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; -import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, formatHours } from '@/lib/dev-task'; -import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; +import { useRequirementStore } from '@/stores/useRequirementStore'; +import { ALLOWED_TRANSITIONS, DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours } from '@/lib/dev-task'; +import type { DevTaskStatus } from '@/lib/dev-task'; interface Props { - task: DevTask; - allTasks: DevTask[]; + taskId: string; + allTaskIds: string[]; onClose: () => void; } -export function DevTaskDetailDrawer({ task, allTasks, onClose }: Props) { - const { changeStatus, setBlocked } = useDevTaskStore(); +export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose }: Props) { + const { tasks, changeStatus, setBlocked } = useDevTaskStore(); const { categories } = useTaskCategoryStore(); + const { requirements } = useRequirementStore(); + + const task = tasks.find((t) => t.id === taskId); + if (!task) return null; + const category = categories.find((c) => c.id === task.categoryId); + const requirement = requirements.find((r) => r.id === task.requirementId); + const allTasks = tasks.filter((t) => allTaskIds.includes(t.id)); + const predecessors = (task.predecessorIds || []).map((id) => allTasks.find((t) => t.id === id)).filter(Boolean); + const nextStatuses = ALLOWED_TRANSITIONS[task.status]; const [blockReason, setBlockReason] = useState(task.blockReason || ''); const [showBlockInput, setShowBlockInput] = useState(false); - const nextStatuses = ALLOWED_TRANSITIONS[task.status]; - const predecessors = (task.predecessorIds || []).map((id) => allTasks.find((t) => t.id === id)).filter(Boolean) as DevTask[]; - const handleTransition = (to: DevTaskStatus) => { const result = changeStatus(task.id, to); if (!result.ok) alert(result.message); @@ -45,85 +52,152 @@ export function DevTaskDetailDrawer({ task, allTasks, onClose }: Props) { return (
-
e.stopPropagation()}> - {/* Header */} -
+
e.stopPropagation()}> + {/* 顶栏 */} +
{task.taskNo} - - {task.isBlocked && 阻塞} + {task.title}
- +
- {/* Body */} -
-

{task.title}

- {task.description &&

{task.description}

} + {/* 滚动区域 */} +
- {/* 状态流转 */} - {nextStatuses.length > 0 && ( -
- 流转到: - {nextStatuses.map((s) => ( - - ))} + {/* 卡片1:关联需求 */} + {requirement && ( +
+
关联需求
+
+ + {requirement.code} + {requirement.title} +
)} - {/* 阻塞管理 */} -
-
- 阻塞状态 - {task.isBlocked ? ( - - ) : ( - + {/* 卡片2:状态 & 操作 */} +
+
状态 & 操作
+
+ + {DEV_TASK_STATUS_LABEL[task.status]} + + {task.isBlocked && ( + + 阻塞中 + )}
- {task.isBlocked && ( -
- - {task.blockReason} + + {/* 流转按钮 */} + {nextStatuses.length > 0 && ( +
+ + {nextStatuses.map((s) => ( + + ))}
)} - {showBlockInput && !task.isBlocked && ( -
- setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleBlock(); }} placeholder="阻塞原因" className="flex-1 h-8 rounded-md border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" /> - -
- )} -
- {/* 基本信息 */} -
-
类型:
-
负责人:{task.assigneeId}
-
优先级:{task.priority}
-
预计工时:{formatHours(task.estimateHours)}
- {task.dueDate &&
截止日期:{task.dueDate}
} - {task.completedAt &&
完成日期:{task.completedAt}
} -
- - {/* 前置任务 */} - {predecessors.length > 0 && ( -
- 前置任务 - {predecessors.map((p) => ( -
- - {p.taskNo} - {p.title} - + {/* 阻塞管理 */} +
+ {task.isBlocked ? ( +
+
+ + {task.blockReason} +
+
- ))} + ) : ( + showBlockInput ? ( +
+ setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleBlock(); }} placeholder="阻塞原因" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus /> + + +
+ ) : ( + + ) + )} +
+
+ + {/* 卡片3:基本信息 */} +
+
基本信息
+
+
+ + 类型 + +
+
+ + 负责人 + {task.assigneeId} +
+
+ 优先级 + {task.priority} +
+
+ + 预计 + {formatHours(task.estimateHours)} +
+
+ + 实际 + {task.actualHours > 0 ? `${task.actualHours}h` : '-'} +
+ {task.dueDate && ( +
+ + 截止 + {task.dueDate} +
+ )} + {task.completedAt && ( +
+ + 完成 + {task.completedAt} +
+ )} +
+ {task.description && ( +
+

{task.description}

+
+ )} +
+ + {/* 卡片4:前置任务 */} + {predecessors.length > 0 && ( +
+
前置任务
+
+ {predecessors.map((p: any) => ( +
+ + {p.taskNo} + {p.title} + +
+ ))} +
)} - {/* 工时记录 */} - + {/* 卡片5:工时记录 */} +
+ +
diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx index 646df94..1006000 100644 --- a/apps/web/components/dev-task/DevTaskRow.tsx +++ b/apps/web/components/dev-task/DevTaskRow.tsx @@ -38,8 +38,10 @@ export function DevTaskRow({ task, category, onClick }: Props) {
- - {task.actualHours > 0 ? `${task.actualHours}h/` : ''}{formatHours(task.estimateHours).split('(')[0]} + + {task.actualHours > 0 && {task.actualHours}h} + {task.actualHours > 0 && ' / '} + {formatHours(task.estimateHours).split('(')[0]} {task.assigneeId}
diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index 9f894d7..4c44352 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -1,27 +1,33 @@ 'use client'; import { useState, useMemo, useEffect } from 'react'; -import { Plus, Code2 } from 'lucide-react'; +import { Plus, Code2, Filter } from 'lucide-react'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; +import { useMemberStore } from '@/stores/useMemberStore'; +import { useAuthStore } from '@/stores/useAuthStore'; import { DevTaskRow } from './DevTaskRow'; import { DevTaskCreateModal } from './DevTaskCreateModal'; import { DevTaskDetailDrawer } from './DevTaskDetailDrawer'; -import { calcGroupProgress } from '@/lib/dev-task'; -import type { DevTask } from '@/lib/dev-task'; +import { calcGroupProgress, DEV_TASK_STATUS_LABEL } from '@/lib/dev-task'; +import { Pagination, usePagination } from '@/components/Pagination'; +import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; interface Props { versionId: string; requirementIds: string[]; + versionDeadline?: string; } -export function DevTaskTab({ versionId, requirementIds }: Props) { +export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props) { const { tasks, fetchTasks } = useDevTaskStore(); const { categories, fetchCategories } = useTaskCategoryStore(); const { fetchWorklogs } = useTaskWorklogStore(); const { requirements } = useRequirementStore(); + const { members } = useMemberStore(); + const user = useAuthStore((s) => s.user); useEffect(() => { fetchTasks(); }, [fetchTasks]); useEffect(() => { fetchCategories(); }, [fetchCategories]); @@ -32,26 +38,56 @@ export function DevTaskTab({ versionId, requirementIds }: Props) { [tasks, requirementIds], ); + // 筛选状态 + const [filterAssignee, setFilterAssignee] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + const [filterBlocked, setFilterBlocked] = useState(''); + const [filterCategory, setFilterCategory] = useState(''); + + // 应用筛选 + const filteredTasks = useMemo(() => { + let result = versionTasks; + if (filterAssignee) result = result.filter((t) => t.assigneeId === filterAssignee); + if (filterStatus) result = result.filter((t) => t.status === filterStatus); + if (filterBlocked === 'yes') result = result.filter((t) => t.isBlocked); + if (filterBlocked === 'no') result = result.filter((t) => !t.isBlocked); + if (filterCategory) result = result.filter((t) => t.categoryId === filterCategory); + return result; + }, [versionTasks, filterAssignee, filterStatus, filterBlocked, filterCategory]); + + // 分页 + const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredTasks, 20); + + // 统计 const progress = calcGroupProgress(versionTasks); const totalEstimate = versionTasks.reduce((s, t) => s + t.estimateHours, 0); const totalActual = versionTasks.reduce((s, t) => s + t.actualHours, 0); const blockedCount = versionTasks.filter((t) => t.isBlocked).length; const [showCreate, setShowCreate] = useState(false); - const [selectedTask, setSelectedTask] = useState(null); + const [selectedTaskId, setSelectedTaskId] = useState(null); + // 按需求分组(对当前页数据) const groupedByReq = useMemo(() => { const map = new Map(); - for (const t of versionTasks) { + for (const t of paged) { const list = map.get(t.requirementId) || []; list.push(t); map.set(t.requirementId, list); } return map; + }, [paged]); + + // 参与人列表 + const assignees = useMemo(() => { + const names = new Set(versionTasks.map((t) => t.assigneeId)); + return Array.from(names); }, [versionTasks]); + const hasFilter = !!(filterAssignee || filterStatus || filterBlocked || filterCategory); + return ( -
+
{/* 统计栏 */}
@@ -72,11 +108,38 @@ export function DevTaskTab({ versionId, requirementIds }: Props) {
- {/* 按需求分组列表 */} - {versionTasks.length === 0 ? ( + {/* 筛选栏 */} +
+ + + + + + {hasFilter && ( + + )} + {filteredTasks.length} 条结果 +
+ + {/* 任务列表 */} + {filteredTasks.length === 0 ? (
-

暂无开发任务

- +

{hasFilter ? '没有匹配的任务' : '暂无开发任务'}

+ {!hasFilter && }
) : ( Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => { @@ -90,15 +153,21 @@ export function DevTaskTab({ versionId, requirementIds }: Props) { {reqProgress}%
{reqTasks.map((t) => ( - c.id === t.categoryId)} onClick={() => setSelectedTask(t)} /> + c.id === t.categoryId)} onClick={() => setSelectedTaskId(t.id)} /> ))}
); }) )} - {showCreate && setShowCreate(false)} />} - {selectedTask && setSelectedTask(null)} />} + {/* 分页 */} + {total > 20 && ( + + )} + + {/* 弹窗 & 抽屉 */} + {showCreate && setShowCreate(false)} />} + {selectedTaskId && t.id)} onClose={() => setSelectedTaskId(null)} />}
); } diff --git a/apps/web/lib/dev-task.ts b/apps/web/lib/dev-task.ts index 1587922..729f285 100644 --- a/apps/web/lib/dev-task.ts +++ b/apps/web/lib/dev-task.ts @@ -23,6 +23,7 @@ export interface DevTask { blockedById?: string; predecessorIds?: string[]; riskLevel?: 'low' | 'medium' | 'high'; + overdueReason?: string; createdBy: string; createdAt: string; updatedAt: string;