feat(dev-task): 开发任务模块 V1 完整实现
- 核心库:dev-task.ts(类型+状态机+进度计算)、task-worklog.ts(工时)、task-category.ts(字典)、work-item.ts(聚合契约) - Store:useDevTaskStore(CRUD+状态流转)、useTaskWorklogStore(工时记录)、useTaskCategoryStore(字典管理) - UI组件:DevTaskTab、DevTaskCreateModal、DevTaskDetailDrawer、DevTaskRow、WorklogPanel、StatusBadge、CategoryChip - 集成:版本详情页"开发任务"Tab、"与我相关"工作台开发任务分组、任务类型管理页 - 设计文档:spec + 实施计划 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
93
apps/web/lib/dev-task.ts
Normal file
93
apps/web/lib/dev-task.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Priority } from './derive';
|
||||
|
||||
export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted' | 'done';
|
||||
|
||||
export interface DevTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
requirementId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
categoryId: string;
|
||||
assigneeId: string;
|
||||
reviewerId?: string;
|
||||
priority: Priority;
|
||||
estimateHours: number;
|
||||
actualHours: number;
|
||||
startDate?: string;
|
||||
dueDate?: string;
|
||||
completedAt?: string;
|
||||
status: DevTaskStatus;
|
||||
isBlocked: boolean;
|
||||
blockReason?: string;
|
||||
blockedById?: string;
|
||||
predecessorIds?: string[];
|
||||
riskLevel?: 'low' | 'medium' | 'high';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const DEV_TASK_STATUS_LABEL: Record<DevTaskStatus, string> = {
|
||||
todo: '待开发',
|
||||
in_progress: '开发中',
|
||||
testing: '自测',
|
||||
submitted: '提测',
|
||||
done: '已完成',
|
||||
};
|
||||
|
||||
export const DEV_TASK_STATUS_COLOR: Record<DevTaskStatus, string> = {
|
||||
todo: 'bg-zinc-100 text-zinc-600',
|
||||
in_progress: 'bg-blue-50 text-blue-600',
|
||||
testing: 'bg-purple-50 text-purple-600',
|
||||
submitted: 'bg-orange-50 text-orange-600',
|
||||
done: 'bg-emerald-50 text-emerald-600',
|
||||
};
|
||||
|
||||
export const STATUS_PROGRESS: Record<DevTaskStatus, number> = {
|
||||
todo: 0,
|
||||
in_progress: 50,
|
||||
testing: 80,
|
||||
submitted: 90,
|
||||
done: 100,
|
||||
};
|
||||
|
||||
export const ALLOWED_TRANSITIONS: Record<DevTaskStatus, DevTaskStatus[]> = {
|
||||
todo: ['in_progress'],
|
||||
in_progress: ['testing'],
|
||||
testing: ['submitted', 'in_progress'],
|
||||
submitted: ['done', 'in_progress'],
|
||||
done: [],
|
||||
};
|
||||
|
||||
export function canTransition(from: DevTaskStatus, to: DevTaskStatus): boolean {
|
||||
return ALLOWED_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export function calcTaskProgress(task: DevTask): number {
|
||||
return STATUS_PROGRESS[task.status];
|
||||
}
|
||||
|
||||
export function calcGroupProgress(tasks: DevTask[]): number {
|
||||
if (tasks.length === 0) return 0;
|
||||
const totalEstimate = tasks.reduce((sum, t) => sum + t.estimateHours, 0);
|
||||
if (totalEstimate === 0) return 0;
|
||||
const weighted = tasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
|
||||
return Math.round(weighted / totalEstimate);
|
||||
}
|
||||
|
||||
export function formatHours(hours: number): string {
|
||||
if (hours < 8) return `${hours}h`;
|
||||
const days = Math.floor(hours / 8);
|
||||
const remainder = hours % 8;
|
||||
if (remainder === 0) return `${hours}h(${days}人天)`;
|
||||
return `${hours}h(≈${days}人天)`;
|
||||
}
|
||||
|
||||
export function generateTaskNo(existingTasks: DevTask[]): string {
|
||||
const maxNum = existingTasks.reduce((max, t) => {
|
||||
const num = parseInt(t.taskNo.replace('DEV-', ''), 10);
|
||||
return isNaN(num) ? max : Math.max(max, num);
|
||||
}, 0);
|
||||
return `DEV-${String(maxNum + 1).padStart(3, '0')}`;
|
||||
}
|
||||
36
apps/web/lib/task-category.ts
Normal file
36
apps/web/lib/task-category.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other';
|
||||
|
||||
export interface TaskCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
group: CategoryGroup;
|
||||
color?: string;
|
||||
sortOrder: number;
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
export const CATEGORY_GROUP_LABEL: Record<CategoryGroup, string> = {
|
||||
development: '开发',
|
||||
testing: '测试',
|
||||
implementation: '实施',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export const PRESET_CATEGORIES: TaskCategory[] = [
|
||||
{ id: 'cat-1', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true },
|
||||
{ id: 'cat-2', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true },
|
||||
{ id: 'cat-3', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 3, isSystem: true },
|
||||
{ id: 'cat-4', name: '接口联调', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true },
|
||||
{ id: 'cat-5', name: '测试验证', group: 'testing', color: '#a855f7', sortOrder: 5, isSystem: true },
|
||||
{ id: 'cat-6', name: '缺陷修复', group: 'testing', color: '#ef4444', sortOrder: 6, isSystem: true },
|
||||
{ id: 'cat-7', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 7, isSystem: true },
|
||||
{ id: 'cat-8', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 8, isSystem: true },
|
||||
];
|
||||
|
||||
export function getCategoryById(categories: TaskCategory[], id: string): TaskCategory | undefined {
|
||||
return categories.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
export function getCategoriesByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory[] {
|
||||
return categories.filter((c) => c.group === group).sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
33
apps/web/lib/task-worklog.ts
Normal file
33
apps/web/lib/task-worklog.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface TaskWorklog {
|
||||
id: string;
|
||||
taskId: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
hours: number;
|
||||
workContent: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function calcActualHours(worklogs: TaskWorklog[], taskId: string): number {
|
||||
return worklogs
|
||||
.filter((w) => w.taskId === taskId)
|
||||
.reduce((sum, w) => sum + w.hours, 0);
|
||||
}
|
||||
|
||||
export function getTaskWorklogs(worklogs: TaskWorklog[], taskId: string): TaskWorklog[] {
|
||||
return worklogs
|
||||
.filter((w) => w.taskId === taskId)
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
export function getUserDailyWorklogs(worklogs: TaskWorklog[], userId: string, date: string): TaskWorklog[] {
|
||||
return worklogs.filter((w) => w.userId === userId && w.date === date);
|
||||
}
|
||||
|
||||
export function getUserDailySummary(worklogs: TaskWorklog[], userId: string, date: string): { total: number; items: { taskId: string; hours: number; workContent: string }[] } {
|
||||
const items = getUserDailyWorklogs(worklogs, userId, date);
|
||||
return {
|
||||
total: items.reduce((sum, w) => sum + w.hours, 0),
|
||||
items: items.map((w) => ({ taskId: w.taskId, hours: w.hours, workContent: w.workContent })),
|
||||
};
|
||||
}
|
||||
49
apps/web/lib/work-item.ts
Normal file
49
apps/web/lib/work-item.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { Priority } from './derive';
|
||||
|
||||
export type WorkItemEntityType = 'plan' | 'devTask' | 'testCase' | 'bug';
|
||||
|
||||
export interface WorkItem {
|
||||
entityType: WorkItemEntityType;
|
||||
entityId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
isBlocked?: boolean;
|
||||
assigneeId: string;
|
||||
reviewerId?: string;
|
||||
versionId: string;
|
||||
versionName?: string;
|
||||
priority?: Priority;
|
||||
dueDate?: string;
|
||||
categoryLabel?: string;
|
||||
}
|
||||
|
||||
export function planToWorkItem(plan: VersionPlan): WorkItem {
|
||||
return {
|
||||
entityType: 'plan',
|
||||
entityId: plan.id,
|
||||
title: plan.title,
|
||||
status: plan.status,
|
||||
assigneeId: plan.owner,
|
||||
versionId: plan.versionId,
|
||||
dueDate: plan.endTime.slice(0, 10),
|
||||
categoryLabel: plan.type === 'research' ? '调研' : plan.type === 'product' ? '产品方案' : 'UI设计',
|
||||
};
|
||||
}
|
||||
|
||||
export function devTaskToWorkItem(task: DevTask, versionId: string, categoryLabel?: string): WorkItem {
|
||||
return {
|
||||
entityType: 'devTask',
|
||||
entityId: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
isBlocked: task.isBlocked,
|
||||
assigneeId: task.assigneeId,
|
||||
reviewerId: task.reviewerId,
|
||||
versionId,
|
||||
priority: task.priority,
|
||||
dueDate: task.dueDate,
|
||||
categoryLabel,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user