Files
ftb-project-management/docs/superpowers/plans/2026-06-11-dev-task-module.md
Script Generator a560634091 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>
2026-06-11 18:02:07 +08:00

1385 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# DevTask 模块实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现开发任务DevTask模块 V1包含数据模型、Store、任务列表、详情、工时记录与"与我相关"聚合接入。
**Architecture:** 前端纯 localStorage Mock遵循已有模块结构。三个 StoreDevTask / TaskWorklog / TaskCategory+ 库函数 + 版本详情页内嵌 Tab + 工作台聚合扩展。
**Tech Stack:** Next.js 14 App Router + TypeScript + Zustand + Tailwind + Shadcn/ui + lucide-react
**Spec 文档:** `docs/specs/2026-06-11-dev-task-module-design.md`
---
## 文件结构概览
| 路径 | 职责 |
|------|------|
| `apps/web/lib/dev-task.ts` | DevTask 类型 + 状态机推导 + 进度公式 + 工时格式化 |
| `apps/web/lib/task-worklog.ts` | TaskWorklog 类型 + 聚合函数 |
| `apps/web/lib/task-category.ts` | TaskCategory 类型 + 系统预置值 |
| `apps/web/lib/work-item.ts` | 统一 WorkItem 聚合契约 + 转换函数 |
| `apps/web/stores/useDevTaskStore.ts` | DevTask CRUD + localStorage 持久化 |
| `apps/web/stores/useTaskWorklogStore.ts` | 工时 CRUD + actualHours 派生 |
| `apps/web/stores/useTaskCategoryStore.ts` | 任务类型字典管理 |
| `apps/web/components/dev-task/DevTaskTab.tsx` | 版本详情页内嵌的开发任务 Tab |
| `apps/web/components/dev-task/DevTaskList.tsx` | 任务列表(分组/筛选/排序) |
| `apps/web/components/dev-task/DevTaskRow.tsx` | 单行任务展示(含状态/工时/阻塞标记) |
| `apps/web/components/dev-task/DevTaskCreateModal.tsx` | 新建任务弹窗 |
| `apps/web/components/dev-task/DevTaskDetailDrawer.tsx` | 任务详情侧抽屉(编辑/工时/前置/阻塞) |
| `apps/web/components/dev-task/WorklogPanel.tsx` | 工时登记面板(列表 + 新增) |
| `apps/web/components/dev-task/StatusBadge.tsx` | 状态徽章 |
| `apps/web/components/dev-task/CategoryChip.tsx` | 任务类型胶囊 |
| `apps/web/app/admin/categories/page.tsx` | 任务类型字典管理页 |
| `apps/web/app/versions/[id]/page.tsx` | 修改DevTask Tab 接入 |
| `apps/web/app/workspace/page.tsx` | 修改:增加"开发任务"分组 |
---
## Task 1: 核心类型库 — dev-task.ts
**Files:**
- Create: `apps/web/lib/dev-task.ts`
- [ ] **Step 1: 创建 dev-task.ts 类型定义与常量**
```typescript
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')}`;
}
```
- [ ] **Step 2: 验证文件无语法错误**
Run: `cd apps/web && npx tsc --noEmit lib/dev-task.ts`
- [ ] **Step 3: Commit**
```bash
git add apps/web/lib/dev-task.ts
git commit -m "feat(dev-task): 添加核心类型定义与进度计算函数"
```
---
## Task 2: 工时记录库 — task-worklog.ts
**Files:**
- Create: `apps/web/lib/task-worklog.ts`
- [ ] **Step 1: 创建 task-worklog.ts**
```typescript
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 })),
};
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/lib/task-worklog.ts
git commit -m "feat(dev-task): 添加工时记录类型与聚合函数"
```
---
## Task 3: 任务类型字典库 — task-category.ts
**Files:**
- Create: `apps/web/lib/task-category.ts`
- [ ] **Step 1: 创建 task-category.ts**
```typescript
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);
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/lib/task-category.ts
git commit -m "feat(dev-task): 添加任务类型字典库与系统预置数据"
```
---
## Task 4: WorkItem 聚合契约 — work-item.ts
**Files:**
- Create: `apps/web/lib/work-item.ts`
- [ ] **Step 1: 创建 work-item.ts**
```typescript
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,
};
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/lib/work-item.ts
git commit -m "feat(dev-task): 添加 WorkItem 统一聚合契约"
```
---
## Task 5: TaskCategory Store — useTaskCategoryStore.ts
**Files:**
- Create: `apps/web/stores/useTaskCategoryStore.ts`
- [ ] **Step 1: 创建 useTaskCategoryStore.ts**
```typescript
'use client';
import { create } from 'zustand';
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
import { PRESET_CATEGORIES } from '@/lib/task-category';
const STORAGE_KEY = 'ftb_task_categories_v1';
function saveLocal(items: TaskCategory[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
}
function loadLocal(): TaskCategory[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
interface TaskCategoryState {
categories: TaskCategory[];
fetchCategories: () => void;
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
deleteCategory: (id: string) => boolean;
}
export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
categories: PRESET_CATEGORIES,
fetchCategories: () => {
const cached = loadLocal();
if (cached) set({ categories: cached });
},
addCategory: (name, group, color) => {
const list = get().categories;
const item: TaskCategory = {
id: `cat-${Date.now()}`,
name,
group,
color,
sortOrder: list.length + 1,
isSystem: false,
};
const updated = [...list, item];
set({ categories: updated });
saveLocal(updated);
},
updateCategory: (id, data) => {
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
set({ categories: updated });
saveLocal(updated);
},
deleteCategory: (id) => {
const target = get().categories.find((c) => c.id === id);
if (!target || target.isSystem) return false;
const updated = get().categories.filter((c) => c.id !== id);
set({ categories: updated });
saveLocal(updated);
return true;
},
}));
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/stores/useTaskCategoryStore.ts
git commit -m "feat(dev-task): 添加任务类型字典 Store"
```
---
## Task 6: TaskWorklog Store — useTaskWorklogStore.ts
**Files:**
- Create: `apps/web/stores/useTaskWorklogStore.ts`
- [ ] **Step 1: 创建 useTaskWorklogStore.ts**
```typescript
'use client';
import { create } from 'zustand';
import type { TaskWorklog } from '@/lib/task-worklog';
const STORAGE_KEY = 'ftb_task_worklogs_v1';
function saveLocal(items: TaskWorklog[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
}
function loadLocal(): TaskWorklog[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
interface TaskWorklogState {
worklogs: TaskWorklog[];
fetchWorklogs: () => void;
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
deleteWorklog: (id: string) => void;
getActualHours: (taskId: string) => number;
}
export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
worklogs: [],
fetchWorklogs: () => {
const cached = loadLocal();
if (cached) set({ worklogs: cached });
},
addWorklog: (data) => {
const item: TaskWorklog = {
...data,
id: `wl-${Date.now()}`,
createdAt: new Date().toISOString(),
};
const updated = [...get().worklogs, item];
set({ worklogs: updated });
saveLocal(updated);
},
deleteWorklog: (id) => {
const updated = get().worklogs.filter((w) => w.id !== id);
set({ worklogs: updated });
saveLocal(updated);
},
getActualHours: (taskId) => {
return get().worklogs
.filter((w) => w.taskId === taskId)
.reduce((sum, w) => sum + w.hours, 0);
},
}));
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/stores/useTaskWorklogStore.ts
git commit -m "feat(dev-task): 添加工时记录 Store"
```
---
## Task 7: DevTask Store — useDevTaskStore.ts
**Files:**
- Create: `apps/web/stores/useDevTaskStore.ts`
- [ ] **Step 1: 创建 useDevTaskStore.ts**
```typescript
'use client';
import { create } from 'zustand';
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
import { canTransition, generateTaskNo } from '@/lib/dev-task';
const STORAGE_KEY = 'ftb_dev_tasks_v1';
function saveLocal(items: DevTask[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
}
function loadLocal(): DevTask[] | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
interface DevTaskState {
tasks: DevTask[];
fetchTasks: () => void;
createTask: (data: Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'actualHours' | 'isBlocked'>) => DevTask;
updateTask: (id: string, data: Partial<DevTask>) => void;
deleteTask: (id: string) => void;
changeStatus: (id: string, to: DevTaskStatus) => { ok: boolean; message?: string };
setBlocked: (id: string, blocked: boolean, reason?: string, blockedById?: string) => void;
syncActualHours: (taskId: string, hours: number) => void;
getByRequirement: (requirementId: string) => DevTask[];
getByVersionViaRequirements: (requirementIds: string[]) => DevTask[];
getByAssignee: (assigneeId: string) => DevTask[];
}
export const useDevTaskStore = create<DevTaskState>((set, get) => ({
tasks: [],
fetchTasks: () => {
const cached = loadLocal();
if (cached) set({ tasks: cached });
},
createTask: (data) => {
const list = get().tasks;
const now = new Date().toISOString();
const task: DevTask = {
...data,
id: `task-${Date.now()}`,
taskNo: generateTaskNo(list),
actualHours: 0,
isBlocked: false,
createdAt: now,
updatedAt: now,
};
const updated = [...list, task];
set({ tasks: updated });
saveLocal(updated);
return task;
},
updateTask: (id, data) => {
const updated = get().tasks.map((t) =>
t.id === id ? { ...t, ...data, updatedAt: new Date().toISOString() } : t,
);
set({ tasks: updated });
saveLocal(updated);
},
deleteTask: (id) => {
const updated = get().tasks.filter((t) => t.id !== id);
set({ tasks: updated });
saveLocal(updated);
},
changeStatus: (id, to) => {
const task = get().tasks.find((t) => t.id === id);
if (!task) return { ok: false, message: '任务不存在' };
if (!canTransition(task.status, to)) {
return { ok: false, message: `不允许从 ${task.status} 流转到 ${to}` };
}
const completedAt = to === 'done' ? new Date().toISOString().slice(0, 10) : task.completedAt;
get().updateTask(id, { status: to, completedAt });
return { ok: true };
},
setBlocked: (id, blocked, reason, blockedById) => {
get().updateTask(id, {
isBlocked: blocked,
blockReason: blocked ? reason : undefined,
blockedById: blocked ? blockedById : undefined,
});
},
syncActualHours: (taskId, hours) => {
get().updateTask(taskId, { actualHours: hours });
},
getByRequirement: (requirementId) => {
return get().tasks.filter((t) => t.requirementId === requirementId);
},
getByVersionViaRequirements: (requirementIds) => {
const set = new Set(requirementIds);
return get().tasks.filter((t) => set.has(t.requirementId));
},
getByAssignee: (assigneeId) => {
return get().tasks.filter((t) => t.assigneeId === assigneeId);
},
}));
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/stores/useDevTaskStore.ts
git commit -m "feat(dev-task): 添加 DevTask Store 含状态机校验"
```
---
## Task 8: 基础 UI 组件 — StatusBadge + CategoryChip
**Files:**
- Create: `apps/web/components/dev-task/StatusBadge.tsx`
- Create: `apps/web/components/dev-task/CategoryChip.tsx`
- [ ] **Step 1: 创建 StatusBadge.tsx**
```tsx
'use client';
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 }) {
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${DEV_TASK_STATUS_COLOR[status]}`}>
{DEV_TASK_STATUS_LABEL[status]}
</span>
);
}
```
- [ ] **Step 2: 创建 CategoryChip.tsx**
```tsx
'use client';
import type { TaskCategory } from '@/lib/task-category';
export function CategoryChip({ category }: { category?: TaskCategory }) {
if (!category) return <span className="text-[11px] text-[var(--ink-muted)]">未分类</span>;
return (
<span
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: category.color ? `${category.color}15` : 'var(--bg-subtle)',
color: category.color || 'var(--ink-soft)',
}}
>
{category.name}
</span>
);
}
```
- [ ] **Step 3: Commit**
```bash
git add apps/web/components/dev-task/StatusBadge.tsx apps/web/components/dev-task/CategoryChip.tsx
git commit -m "feat(dev-task): 添加 StatusBadge 和 CategoryChip 基础组件"
```
---
## Task 9: 工时登记面板 — WorklogPanel.tsx
**Files:**
- Create: `apps/web/components/dev-task/WorklogPanel.tsx`
- [ ] **Step 1: 创建 WorklogPanel.tsx**
面板展示指定任务的工时记录列表 + 新增表单date + hours + workContent
```tsx
'use client';
import { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { getTaskWorklogs } from '@/lib/task-worklog';
interface Props {
taskId: string;
}
export function WorklogPanel({ taskId }: Props) {
const { worklogs, addWorklog, deleteWorklog } = useTaskWorklogStore();
const { syncActualHours } = useDevTaskStore();
const user = useAuthStore((s) => s.user);
const taskLogs = getTaskWorklogs(worklogs, taskId);
const totalHours = taskLogs.reduce((sum, w) => sum + w.hours, 0);
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
const [hours, setHours] = useState<number>(1);
const [workContent, setWorkContent] = useState('');
const [adding, setAdding] = useState(false);
const handleAdd = () => {
if (!workContent.trim() || hours <= 0) return;
addWorklog({ taskId, userId: user?.name || '', date, hours, workContent: workContent.trim() });
const newTotal = totalHours + hours;
syncActualHours(taskId, newTotal);
setWorkContent('');
setHours(1);
setAdding(false);
};
const handleDelete = (id: string, logHours: number) => {
deleteWorklog(id);
syncActualHours(taskId, Math.max(0, totalHours - logHours));
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--ink)]">工时记录(累计 {totalHours}h</span>
{!adding && (
<button onClick={() => setAdding(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
<Plus className="h-3 w-3" />登记工时
</button>
)}
</div>
{adding && (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3 space-y-2">
<div className="grid grid-cols-2 gap-2">
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="h-8 rounded-md border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<input type="number" min={0.5} step={0.5} value={hours} onChange={(e) => setHours(parseFloat(e.target.value) || 0)} placeholder="小时" className="h-8 rounded-md border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<input value={workContent} onChange={(e) => setWorkContent(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }} placeholder="工作内容(必填)" className="h-8 w-full rounded-md border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<div className="flex justify-end gap-2">
<button onClick={() => setAdding(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)] hover:text-[var(--ink)]">取消</button>
<button onClick={handleAdd} disabled={!workContent.trim() || hours <= 0} className="h-7 px-3 rounded-md text-[11px] font-medium bg-[var(--accent)] text-white disabled:opacity-50">确认</button>
</div>
</div>
)}
{taskLogs.length === 0 && !adding && (
<p className="text-[12px] text-[var(--ink-muted)] py-2">暂无工时记录</p>
)}
{taskLogs.map((log) => (
<div key={log.id} className="flex items-start gap-2 py-1.5 border-b border-[var(--line)] last:border-0">
<div className="flex-1">
<p className="text-[12px] text-[var(--ink)]">{log.workContent}</p>
<p className="text-[10px] text-[var(--ink-muted)]">{log.date} · {log.hours}h · {log.userId}</p>
</div>
<button onClick={() => handleDelete(log.id, log.hours)} className="p-1 rounded hover:bg-red-50">
<Trash2 className="h-3 w-3 text-[var(--ink-muted)] hover:text-red-500" />
</button>
</div>
))}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/components/dev-task/WorklogPanel.tsx
git commit -m "feat(dev-task): 添加工时登记面板组件"
```
---
## Task 10: 任务列表行 — DevTaskRow.tsx
**Files:**
- Create: `apps/web/components/dev-task/DevTaskRow.tsx`
- [ ] **Step 1: 创建 DevTaskRow.tsx**
单行展示:编号、标题、状态徽章、类型胶囊、负责人、工时(预计/实际)、阻塞标记、优先级。
```tsx
'use client';
import { AlertTriangle } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip';
import { formatHours } from '@/lib/dev-task';
import type { DevTask } from '@/lib/dev-task';
import type { TaskCategory } from '@/lib/task-category';
interface Props {
task: DevTask;
category?: TaskCategory;
onClick?: () => void;
}
const PRIORITY_DOT: Record<string, string> = {
P0: 'bg-red-500',
P1: 'bg-orange-400',
P2: 'bg-blue-400',
P3: 'bg-zinc-300',
};
export function DevTaskRow({ task, category, onClick }: Props) {
return (
<div
onClick={onClick}
className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors"
>
{/* 优先级圆点 */}
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[task.priority] || 'bg-zinc-300'}`} title={task.priority} />
{/* 编号 */}
<span className="text-[11px] font-mono text-[var(--ink-muted)] w-16 shrink-0">{task.taskNo}</span>
{/* 标题 + 阻塞标记 */}
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-[13px] text-[var(--ink)] truncate">{task.title}</span>
{task.isBlocked && (
<span className="flex items-center gap-0.5 text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded" title={task.blockReason}>
<AlertTriangle className="h-2.5 w-2.5" />阻塞
</span>
)}
</div>
{/* 类型 */}
<CategoryChip category={category} />
{/* 状态 */}
<StatusBadge status={task.status} />
{/* 工时 */}
<span className="text-[11px] text-[var(--ink-muted)] w-20 text-right tabular-nums">
{task.actualHours > 0 ? `${task.actualHours}h/` : ''}{formatHours(task.estimateHours).split('')[0]}
</span>
{/* 负责人 */}
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate">{task.assigneeId}</span>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/components/dev-task/DevTaskRow.tsx
git commit -m "feat(dev-task): 添加任务列表行组件"
```
---
## Task 11: 任务详情侧抽屉 — DevTaskDetailDrawer.tsx
**Files:**
- Create: `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`
- [ ] **Step 1: 创建 DevTaskDetailDrawer.tsx**
侧抽屉展示完整任务信息:状态流转按钮、阻塞管理、基本信息编辑、工时面板、前置任务列表。
```tsx
'use client';
import { useState } from 'react';
import { X, ChevronRight, AlertTriangle, Link2 } 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';
interface Props {
task: DevTask;
allTasks: DevTask[];
onClose: () => void;
}
export function DevTaskDetailDrawer({ task, allTasks, onClose }: Props) {
const { changeStatus, setBlocked, updateTask } = useDevTaskStore();
const { categories } = useTaskCategoryStore();
const category = categories.find((c) => c.id === task.categoryId);
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);
};
const handleBlock = () => {
if (!blockReason.trim()) return;
setBlocked(task.id, true, blockReason.trim());
setShowBlockInput(false);
};
const handleUnblock = () => {
setBlocked(task.id, false);
setBlockReason('');
};
return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-md h-full bg-[var(--bg-card)] border-l border-[var(--line)] shadow-[var(--shadow-lg)] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between h-14 px-5 border-b border-[var(--line)] shrink-0">
<div className="flex items-center gap-2">
<span className="text-[12px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
<StatusBadge status={task.status} />
{task.isBlocked && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">阻塞</span>}
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4" /></button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* 标题 */}
<h2 className="text-[15px] font-semibold text-[var(--ink)]">{task.title}</h2>
{task.description && <p className="text-[12px] text-[var(--ink-soft)]">{task.description}</p>}
{/* 状态流转 */}
{nextStatuses.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[11px] text-[var(--ink-muted)]">流转到:</span>
{nextStatuses.map((s) => (
<button key={s} onClick={() => handleTransition(s)} className="h-7 px-3 rounded-md text-[11px] font-medium border border-[var(--line)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
{DEV_TASK_STATUS_LABEL[s]}
</button>
))}
</div>
)}
{/* 阻塞管理 */}
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--ink)]">阻塞状态</span>
{task.isBlocked ? (
<button onClick={handleUnblock} className="text-[11px] text-emerald-600 hover:underline">解除阻塞</button>
) : (
<button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 hover:underline">标记阻塞</button>
)}
</div>
{task.isBlocked && (
<div className="flex items-start gap-1.5 text-[12px] text-red-600 bg-red-50 rounded px-2 py-1.5">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{task.blockReason}</span>
</div>
)}
{showBlockInput && !task.isBlocked && (
<div className="flex gap-2">
<input value={blockReason} onChange={(e) => 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" />
<button onClick={handleBlock} className="h-8 px-3 rounded-md text-[11px] font-medium bg-red-500 text-white">确认</button>
</div>
)}
</div>
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-3 text-[12px]">
<div><span className="text-[var(--ink-muted)]">类型:</span><CategoryChip category={category} /></div>
<div><span className="text-[var(--ink-muted)]">负责人:</span><span className="text-[var(--ink)]">{task.assigneeId}</span></div>
<div><span className="text-[var(--ink-muted)]">优先级:</span><span className="text-[var(--ink)]">{task.priority}</span></div>
<div><span className="text-[var(--ink-muted)]">预计工时:</span><span className="text-[var(--ink)]">{formatHours(task.estimateHours)}</span></div>
{task.dueDate && <div><span className="text-[var(--ink-muted)]">截止日期:</span><span className="text-[var(--ink)]">{task.dueDate}</span></div>}
{task.completedAt && <div><span className="text-[var(--ink-muted)]">完成日期:</span><span className="text-[var(--ink)]">{task.completedAt}</span></div>}
</div>
{/* 前置任务 */}
{predecessors.length > 0 && (
<div className="space-y-1.5">
<span className="text-[12px] font-medium text-[var(--ink)]">前置任务</span>
{predecessors.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-[12px] px-2 py-1 rounded bg-[var(--bg-subtle)]">
<Link2 className="h-3 w-3 text-[var(--ink-muted)]" />
<span className="font-mono text-[var(--ink-muted)]">{p.taskNo}</span>
<span className="text-[var(--ink)]">{p.title}</span>
<StatusBadge status={p.status} />
</div>
))}
</div>
)}
{/* 工时记录 */}
<WorklogPanel taskId={task.id} />
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/components/dev-task/DevTaskDetailDrawer.tsx
git commit -m "feat(dev-task): 添加任务详情侧抽屉组件"
```
---
## Task 12: DevTaskTab 组件 — 版本详情内嵌
**Files:**
- Create: `apps/web/components/dev-task/DevTaskTab.tsx`
- [ ] **Step 1: 创建 DevTaskTab.tsx**
负责在版本详情页 "开发任务" Tab 下展示:进度统计栏 + 新建按钮 + 按需求分组的任务列表 + 详情抽屉。
```tsx
'use client';
import { useState, useMemo, useEffect } from 'react';
import { Plus, Code2 } from 'lucide-react';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { DevTaskRow } from './DevTaskRow';
import { DevTaskCreateModal } from './DevTaskCreateModal';
import { DevTaskDetailDrawer } from './DevTaskDetailDrawer';
import { calcGroupProgress, formatHours } from '@/lib/dev-task';
import type { DevTask } from '@/lib/dev-task';
interface Props {
versionId: string;
requirementIds: string[];
}
export function DevTaskTab({ versionId, requirementIds }: Props) {
const { tasks, fetchTasks } = useDevTaskStore();
const { categories, fetchCategories } = useTaskCategoryStore();
const { fetchWorklogs } = useTaskWorklogStore();
const { requirements } = useRequirementStore();
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
const versionTasks = useMemo(
() => tasks.filter((t) => requirementIds.includes(t.requirementId)),
[tasks, requirementIds],
);
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<DevTask | null>(null);
const groupedByReq = useMemo(() => {
const map = new Map<string, DevTask[]>();
for (const t of versionTasks) {
const list = map.get(t.requirementId) || [];
list.push(t);
map.set(t.requirementId, list);
}
return map;
}, [versionTasks]);
return (
<div className="space-y-4">
{/* 统计栏 */}
<div className="flex items-center gap-4 px-4 py-3 rounded-lg bg-[var(--bg-subtle)] border border-[var(--line)]">
<div className="flex items-center gap-2">
<Code2 className="h-4 w-4 text-[var(--accent)]" />
<span className="text-[13px] font-medium text-[var(--ink)]">开发进度 {progress}%</span>
</div>
<div className="h-2 flex-1 rounded-full bg-[var(--bg)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums">
{versionTasks.length} 任务 · 投入 {totalActual}h / 预计 {totalEstimate}h
</span>
{blockedCount > 0 && (
<span className="text-[11px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">{blockedCount} 阻塞</span>
)}
<button onClick={() => setShowCreate(true)} className="ml-auto flex items-center gap-1 h-7 px-3 rounded-md text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
<Plus className="h-3 w-3" />新建任务
</button>
</div>
{/* 按需求分组列表 */}
{versionTasks.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
<p className="text-[13px] text-[var(--ink-muted)]">暂无开发任务</p>
<button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个任务</button>
</div>
) : (
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
const req = requirements.find((r) => r.id === reqId);
const reqProgress = calcGroupProgress(reqTasks);
return (
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-subtle)] border-b border-[var(--line)]">
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req?.code}</span>
<span className="text-[12px] font-medium text-[var(--ink)] flex-1 truncate">{req?.title}</span>
<span className="text-[11px] text-[var(--ink-muted)]">{reqProgress}%</span>
</div>
{reqTasks.map((t) => (
<DevTaskRow key={t.id} task={t} category={categories.find((c) => c.id === t.categoryId)} onClick={() => setSelectedTask(t)} />
))}
</div>
);
})
)}
{/* 弹窗 & 抽屉 */}
{showCreate && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} onClose={() => setShowCreate(false)} />}
{selectedTask && <DevTaskDetailDrawer task={selectedTask} allTasks={versionTasks} onClose={() => setSelectedTask(null)} />}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/web/components/dev-task/DevTaskTab.tsx
git commit -m "feat(dev-task): 添加 DevTaskTab 版本详情内嵌组件"
```
---
## Task 13: 版本详情页接入 DevTaskTab
**Files:**
- Modify: `apps/web/app/versions/[id]/page.tsx`
- [ ] **Step 1: 在版本详情页导入 DevTaskTab 并接入 "开发任务" Tab**
在文件顶部增加导入:
```tsx
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
```
在 Tab 内容渲染区域,找到 `activeTab === 'tasks'` 对应的占位区域(当前显示"功能开发中,敬请期待"),替换为:
```tsx
{activeTab === 'tasks' && (
<DevTaskTab
versionId={versionId}
requirementIds={versionReqs.map((r) => r.id)}
/>
)}
```
其中 `versionReqs` 是该版本已关联的需求列表(已在页面中通过 `requirements.filter(r => r.versionId === versionId)` 获取)。
- [ ] **Step 2: 在浏览器中验证**
Run: `pnpm dev --filter=web`
打开 http://localhost:3000/versions/{任意版本id},切换到"开发任务" Tab
- 应看到空状态 + "创建第一个任务"按钮
- 点击新建,填写表单创建任务
- 创建后列表出现,点击行展开详情抽屉
- 测试登记工时、标记阻塞、状态流转
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/versions/[id]/page.tsx
git commit -m "feat(dev-task): 版本详情页接入开发任务 Tab"
```
---
## Task 14: "与我相关"工作台扩展
**Files:**
- Modify: `apps/web/app/workspace/page.tsx`
- [ ] **Step 1: 扩展工作台支持 DevTask 实体**
在 workspace/page.tsx 中:
1. 导入新依赖:
```tsx
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { DEV_TASK_STATUS_LABEL } from '@/lib/dev-task';
import { StatusBadge } from '@/components/dev-task/StatusBadge';
```
2. 扩展 TabKey 类型和 TABS 数组,增加 `'devTask'` 分组:
```tsx
type TabKey = 'all' | 'research' | 'product' | 'ui' | 'devTask';
// 在 TABS 数组末尾追加:
{ key: 'devTask', label: '开发任务', icon: Code2 },
```
3. 在组件内获取 DevTask 数据:
```tsx
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { categories, fetchCategories } = useTaskCategoryStore();
useEffect(() => { fetchTasks(); }, [fetchTasks]);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
```
4. 筛选"我的开发任务"
```tsx
const myDevTasks = useMemo(
() => devTasks.filter((t) => t.assigneeId === userName && t.status !== 'done'),
[devTasks, userName],
);
```
5. 更新 counts 对象增加 devTask 计数:
```tsx
devTask: myDevTasks.length,
```
6. 更新 all 计数为 `myPlans.length + myDevTasks.length`
7. 在 filtered 渲染区域,当 `activeTab === 'devTask'` 时渲染开发任务卡片(复用 DevTaskRow 或简化展示)。
- [ ] **Step 2: 在浏览器验证**
打开 http://localhost:3000/workspace
- 左侧导航出现"开发任务"分组
- 计数正确
- 点击后右侧展示我负责的未完成开发任务
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/workspace/page.tsx
git commit -m "feat(dev-task): 与我相关工作台接入开发任务聚合"
```
---
## Task 15: 任务类型管理页
**Files:**
- Create: `apps/web/app/admin/categories/page.tsx`
- [ ] **Step 1: 创建 admin/categories/page.tsx**
管理页面:展示所有任务类型列表(按 group 分组),支持新增/编辑名称/删除(系统预置项不可删除)。
```tsx
'use client';
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Shield } from 'lucide-react';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { CATEGORY_GROUP_LABEL } from '@/lib/task-category';
import type { CategoryGroup } from '@/lib/task-category';
export default function CategoriesPage() {
const { categories, fetchCategories, addCategory, updateCategory, deleteCategory } = useTaskCategoryStore();
useEffect(() => { fetchCategories(); }, [fetchCategories]);
const [newName, setNewName] = useState('');
const [newGroup, setNewGroup] = useState<CategoryGroup>('development');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const groups: CategoryGroup[] = ['development', 'testing', 'implementation', 'other'];
const handleAdd = () => {
if (!newName.trim()) return;
addCategory(newName.trim(), newGroup);
setNewName('');
};
const handleSaveEdit = (id: string) => {
if (editName.trim()) updateCategory(id, { name: editName.trim() });
setEditingId(null);
};
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<h1 className="text-[16px] font-semibold text-[var(--ink)]">任务类型管理</h1>
{/* 新增 */}
<div className="flex gap-2">
<input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }} placeholder="新类型名称" className="flex-1 h-9 rounded-lg border border-[var(--line)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
<select value={newGroup} onChange={(e) => setNewGroup(e.target.value as CategoryGroup)} className="h-9 rounded-lg border border-[var(--line)] px-3 text-[13px]">
{groups.map((g) => <option key={g} value={g}>{CATEGORY_GROUP_LABEL[g]}</option>)}
</select>
<button onClick={handleAdd} className="h-9 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]"><Plus className="h-3.5 w-3.5 inline" /> 添加</button>
</div>
{/* 分组列表 */}
{groups.map((g) => {
const items = categories.filter((c) => c.group === g);
if (items.length === 0) return null;
return (
<div key={g}>
<h2 className="text-[13px] font-medium text-[var(--ink-soft)] mb-2">{CATEGORY_GROUP_LABEL[g]}</h2>
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
{items.map((cat) => (
<div key={cat.id} className="flex items-center gap-3 px-4 py-2.5">
{cat.color && <span className="h-3 w-3 rounded-full" style={{ backgroundColor: cat.color }} />}
{editingId === cat.id ? (
<input value={editName} onChange={(e) => setEditName(e.target.value)} onBlur={() => handleSaveEdit(cat.id)} onKeyDown={(e) => { if (e.key === 'Enter') handleSaveEdit(cat.id); }} autoFocus className="flex-1 h-7 rounded border border-[var(--line)] px-2 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<span className="flex-1 text-[13px] text-[var(--ink)]">{cat.name}</span>
)}
{cat.isSystem && <Shield className="h-3 w-3 text-[var(--ink-muted)]" title="系统预置" />}
{!cat.isSystem && (
<>
<button onClick={() => { setEditingId(cat.id); setEditName(cat.name); }} className="p-1 rounded hover:bg-[var(--bg-subtle)]"><Pencil className="h-3 w-3 text-[var(--ink-muted)]" /></button>
<button onClick={() => deleteCategory(cat.id)} className="p-1 rounded hover:bg-red-50"><Trash2 className="h-3 w-3 text-[var(--ink-muted)] hover:text-red-500" /></button>
</>
)}
</div>
))}
</div>
</div>
);
})}
</div>
);
}
```
- [ ] **Step 2: 验证页面加载正常**
打开 http://localhost:3000/admin/categories确认
- 分组显示正确
- 系统预置项有盾牌图标,无删除按钮
- 可新增自定义类型
- 可编辑/删除自定义类型
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/admin/categories/page.tsx
git commit -m "feat(dev-task): 添加任务类型管理页面"
```
---
## Task 16: 最终验证 & 收尾
- [ ] **Step 1: 完整流程验证**
在浏览器中走完整流程:
1. 打开版本详情页 → "开发任务" Tab → 新建任务(填写所有字段)
2. 创建多个任务(不同需求、不同类型、不同负责人)
3. 打开详情抽屉 → 流转状态todo → in_progress → testing → submitted → done
4. 测试回退testing → in_progress
5. 标记阻塞 → 解除阻塞
6. 登记工时 → 确认 actualHours 累加正确
7. 验证进度条随状态变化
8. 打开"与我相关" → 确认开发任务出现在个人工作台
9. 打开"任务类型管理" → 新增/编辑/删除自定义类型
10. 刷新页面,确认 localStorage 持久化正常
- [ ] **Step 2: Commit all remaining changes**
```bash
git add -A
git commit -m "feat(dev-task): 开发任务模块 V1 完成(类型库+Store+UI+工作台聚合)"
```