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:
73
apps/web/app/admin/categories/page.tsx
Normal file
73
apps/web/app/admin/categories/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'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 mr-1" />添加</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 shrink-0" 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 && <span title="系统预置"><Shield className="h-3 w-3 text-[var(--ink-muted)]" /></span>}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { REQ_STATUS_LABEL, REQ_STATUS_COLOR } from '@/lib/requirement';
|
||||
import { OVERTIME_REASON_LABEL } from '@/lib/overtime';
|
||||
import { VersionRequirementsTab } from '@/components/version/VersionRequirementsTab';
|
||||
import { PlanTab } from '@/components/version/PlanTab';
|
||||
import { DevTaskTab } from '@/components/dev-task/DevTaskTab';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
|
||||
@@ -450,6 +451,16 @@ export default function VersionDetailPage() {
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : activeTab === 'tasks' ? (
|
||||
(() => {
|
||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||
return (
|
||||
<DevTaskTab
|
||||
versionId={version.id}
|
||||
requirementIds={versionReqs.map((r) => r.id)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
||||
<span className="text-[13px] text-[var(--ink-muted)]">功能开发中,敬请期待</span>
|
||||
|
||||
@@ -2,22 +2,27 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp } from 'lucide-react';
|
||||
import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp, Code2 } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { flattenVersions } from '@/lib/derive';
|
||||
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, formatHours } from '@/lib/dev-task';
|
||||
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
||||
import type { DevTask } from '@/lib/dev-task';
|
||||
|
||||
type TabKey = 'all' | 'research' | 'product' | 'ui';
|
||||
type TabKey = 'all' | 'research' | 'product' | 'ui' | 'devTask';
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||
{ key: 'all', label: '全部待办', icon: ClipboardList },
|
||||
{ key: 'research', label: '调研', icon: Search },
|
||||
{ key: 'product', label: '产品方案', icon: FileText },
|
||||
{ key: 'ui', label: 'UI设计', icon: Palette },
|
||||
{ key: 'devTask', label: '开发任务', icon: Code2 },
|
||||
];
|
||||
|
||||
export default function WorkspacePage() {
|
||||
@@ -25,6 +30,8 @@ export default function WorkspacePage() {
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { plans, fetchPlans, updatePlan, completePlan } = useVersionPlanStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||
@@ -32,6 +39,8 @@ export default function WorkspacePage() {
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||
|
||||
const userName = user?.name ?? '';
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
@@ -42,14 +51,21 @@ export default function WorkspacePage() {
|
||||
[plans, userName]
|
||||
);
|
||||
|
||||
// 我负责的所有未完成开发任务
|
||||
const myDevTasks = useMemo(() =>
|
||||
devTasks.filter((t) => t.assigneeId === userName && t.status !== 'done'),
|
||||
[devTasks, userName]
|
||||
);
|
||||
|
||||
const counts = {
|
||||
all: myPlans.length,
|
||||
all: myPlans.length + myDevTasks.length,
|
||||
research: myPlans.filter((p) => p.type === 'research').length,
|
||||
product: myPlans.filter((p) => p.type === 'product').length,
|
||||
ui: myPlans.filter((p) => p.type === 'ui').length,
|
||||
devTask: myDevTasks.length,
|
||||
};
|
||||
|
||||
const filtered = activeTab === 'all' ? myPlans : myPlans.filter((p) => p.type === activeTab);
|
||||
const filtered = activeTab === 'all' ? myPlans : activeTab === 'devTask' ? [] : myPlans.filter((p) => p.type === activeTab);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const toggleTask = (plan: VersionPlan, task: PlanTask) => {
|
||||
@@ -97,11 +113,44 @@ export default function WorkspacePage() {
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">
|
||||
{TABS.find((t) => t.key === activeTab)?.label}
|
||||
</h2>
|
||||
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filtered.length} 项</span>
|
||||
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{activeTab === 'devTask' ? myDevTasks.length : filtered.length} 项</span>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)] space-y-3">
|
||||
{filtered.length === 0 ? (
|
||||
{activeTab === 'devTask' ? (
|
||||
myDevTasks.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>
|
||||
</div>
|
||||
) : (
|
||||
myDevTasks.map((task) => {
|
||||
const cat = categories.find((c) => c.id === task.categoryId);
|
||||
const req = requirements.find((r) => r.id === task.requirementId);
|
||||
return (
|
||||
<div key={task.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${DEV_TASK_STATUS_COLOR[task.status]}`}>
|
||||
{DEV_TASK_STATUS_LABEL[task.status]}
|
||||
</span>
|
||||
{task.isBlocked && <span className="text-[10px] text-red-500 bg-red-50 px-1.5 py-0.5 rounded">阻塞</span>}
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
|
||||
<span className="text-[14px] font-medium text-[var(--ink)]">{task.title}</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{task.priority}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[11px] text-[var(--ink-muted)]">
|
||||
{req && <span>{req.code} {req.title}</span>}
|
||||
{cat && <span className="px-1.5 py-0.5 rounded text-[10px]" style={{ backgroundColor: cat.color ? `${cat.color}15` : undefined, color: cat.color }}>{cat.name}</span>}
|
||||
<span>预计 {formatHours(task.estimateHours)}</span>
|
||||
{task.actualHours > 0 && <span>已投入 {task.actualHours}h</span>}
|
||||
{task.dueDate && <span>截止 {task.dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : filtered.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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user