diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d3684cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,256 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## ⚠️ 必读文档(开始任何工作前先读) + +以下 4 个文档定义了项目的架构、决策、流程和路线图。**每次新会话开始时必须先读这些**,避免重新讨论已决定的方案: + +- `docs/architecture.md` — 整体架构、心智模型、关键设计原则 +- `docs/decisions.md` — 关键设计决策记录(含为什么这么做) +- `docs/workflow.md` — 工作流程、协作偏好、命名规范 +- `docs/roadmap.md` — V1/V2/V3 路线图和已完成清单 + +文档更新触发条件: +- **architecture.md**:新增核心模块、模块边界变更、系统架构调整、新增服务、数据流变化 +- **decisions.md**:方案评审完成、多方案比较后定方案、废弃旧方案、重要设计决策 +- **workflow.md**:新增业务流程、流程节点修改、状态机变更、审批流程变更 +- **roadmap.md**:Phase 完成、Milestone 完成、新增计划、优先级调整 + +不影响以上四类的小改动(UI 排版、bug fix、文案)不需要更新文档。 + +## Project Overview + +FTB 智能项目管理系统 — 一个集成 AI 能力的项目管理平台,核心层级:产品 → 项目 → 迭代 → 任务。 + +### 核心功能模块 + +- **产品管理**:产品 CRUD,作为最顶层组织容器 +- **需求池**:需求创建、编辑、状态流转(draft → reviewing → approved/rejected → delivered) +- **项目管理**:归属于产品,项目 CRUD + 成员管理 +- **版本管理**:产品发布版本,关联任务追踪发布范围 +- **迭代管理**:项目内 Sprint,时间盒开发周期 +- **任务管理**:任务 CRUD、状态机、看板视图、甘特图、子任务 +- **与我相关**:个人任务汇总(分配给我的、我创建的、我关注的) +- **成员与权限**:用户管理 + 项目级 RBAC(Owner/Admin/Member/Viewer) +- **AI 辅助**:任务智能分解、工期预估、风险预警 + +## Tech Stack + +| 层级 | 技术 | 说明 | +|------|------|------| +| 前端框架 | Next.js 14+ (App Router) | SSR + 文件路由 | +| UI 组件 | Shadcn/ui + Tailwind CSS | 现代风格,完全可定制 | +| 状态管理 | Zustand | 轻量级,替代 Redux | +| 拖拽 | dnd-kit | 看板拖拽交互 | +| 图表 | Recharts | 燃尽图、数据看板 | +| 后端框架 | NestJS | 模块化架构,TypeScript | +| ORM | Prisma | 类型安全的数据库访问 | +| 数据库 | PostgreSQL | 关系型数据,适合任务依赖建模 | +| AI 集成 | Anthropic SDK | 任务分解、风险分析、智能建议 | +| 认证 | NextAuth.js | OAuth + JWT | +| 实时通信 | Socket.io | 看板实时同步、通知推送 | + +## Architecture + +``` +ftb-project-management/ +├── apps/ +│ ├── web/ # Next.js 前端 +│ │ ├── app/ +│ │ │ ├── products/ # 产品列表 + 详情(含需求池)✅ +│ │ │ ├── projects/ # 项目相关页面(待实现) +│ │ │ ├── workspace/ # "与我相关"(待实现) +│ │ │ └── admin/ # 全局管理(待实现) +│ │ ├── components/ +│ │ │ ├── product/ # 产品+需求组件 ✅ +│ │ │ ├── ui/ # Shadcn 基础组件 +│ │ │ ├── board/ # 看板组件(待实现) +│ │ │ └── gantt/ # 甘特图组件(待实现) +│ │ ├── stores/ # Zustand stores ✅ +│ │ ├── hooks/ # 自定义 Hooks +│ │ └── lib/ # API 封装 + 常量 ✅ +│ └── server/ # NestJS 后端 +│ ├── src/ +│ │ ├── modules/ +│ │ │ ├── product/ # 产品 CRUD ✅ +│ │ │ ├── requirement/ # 需求管理 + 状态机 ✅ +│ │ │ ├── project/ # 项目(待实现) +│ │ │ ├── version/ # 版本(待实现) +│ │ │ ├── sprint/ # 迭代(待实现) +│ │ │ ├── task/ # 任务(待实现) +│ │ │ ├── member/ # 成员权限(待实现) +│ │ │ ├── dashboard/ # 与我相关(待实现) +│ │ │ └── ai/ # AI 能力(待实现) +│ │ ├── prisma/ # PrismaService(全局) ✅ +│ │ └── common/ # 守卫、拦截器、管道 +│ └── prisma/ # Schema + Migrations ✅ +├── packages/ +│ └── shared/ # 前后端共享类型、枚举 ✅ +├── docker-compose.yml # PostgreSQL + Redis +└── turbo.json # Turborepo monorepo 管理 +``` + +## Build & Dev Commands + +```bash +# 安装依赖(monorepo 根目录) +pnpm install + +# 启动全部服务(前端 + 后端 + 数据库) +pnpm dev + +# 单独启动 +pnpm dev --filter=web # 前端 localhost:3000 +pnpm dev --filter=server # 后端 localhost:3001 + +# 数据库 +pnpm db:migrate # 执行 Prisma 迁移 +pnpm db:seed # 填充测试数据 +pnpm db:studio # 打开 Prisma Studio + +# 测试 +pnpm test # 全部测试 +pnpm test --filter=server # 仅后端测试 +pnpm test -- --watch # 监听模式 +pnpm test -- -t "任务创建" # 运行单个测试 + +# 构建 & 检查 +pnpm build # 生产构建 +pnpm lint # ESLint 检查 +pnpm type-check # TypeScript 类型检查 + +# Docker +docker-compose up -d # 启动 PostgreSQL + Redis +docker-compose down # 停止容器 +``` + +## Data Model (核心实体关系) + +``` +Product(产品,顶层容器) +├── Requirement(需求池) +├── Version(发布版本) +└── Project(项目) + ├── Sprint(迭代) + └── Task(任务) + ├── Task(子任务,自引用) + ├── Comment(评论) + └── TaskWatcher(关注者) + +User ── ProjectMember(项目成员 + 角色) +``` + +关键设计决策: +- 需求状态机:`draft → reviewing → approved/rejected → delivered`,rejected 可回退到 draft +- 任务状态机:`todo → in_progress → in_review → done → closed` +- 任务支持无限层级子任务(parent_id 自引用) +- 需求可一键转为任务(Requirement → Task) +- 任务可关联到版本(标记发布范围) +- 权限模型:Owner > Admin > Member > Viewer(项目级 RBAC) +- AI 操作记录独立表存储(AiLog),便于审计和 token 追踪 + +## API Endpoints(已实现) + +``` +# 产品 +GET/POST /api/v1/products +GET/PATCH/DELETE /api/v1/products/:id + +# 需求(嵌套在产品下) +GET/POST /api/v1/products/:productId/requirements +GET/PATCH/DELETE /api/v1/products/:productId/requirements/:id +PATCH /api/v1/products/:productId/requirements/:id/status +``` + +## AI Module 设计 + +AI 模块作为独立 NestJS Module,对外暴露服务接口: + +- `AiTaskService.decompose(description)` — 将需求描述拆解为子任务 +- `AiRiskService.analyze(projectId)` — 分析项目风险并生成预警 +- `AiScheduleService.suggest(projectId)` — 基于成员负载给出排期建议 + +所有 AI 调用走统一的 `AiGateway`,负责 prompt 管理、token 计量、降级处理。 + +## Conventions + +- 包管理器:pnpm(monorepo workspace) +- 分支命名:`feature/模块-描述`、`fix/模块-描述`、`hotfix/描述` +- Commit 格式:`类型(模块): 描述`(中文) + - 类型:feat / fix / refactor / docs / test / chore +- API 路径:RESTful 嵌套资源,如 `/api/v1/products/:productId/requirements/:id` +- 前端路由:`/products/[id]`、`/projects/[id]/board`、`/projects/[id]/gantt` +- 数据库表名 snake_case,TypeScript 字段 camelCase(Prisma `@map` 映射) +- 组件文件 PascalCase,工具函数文件 camelCase +- Zustand store 按功能域拆分:`useProductStore`、`useRequirementStore` + +### 后端模块开发模式 + +每个 NestJS 业务模块遵循统一结构: + +``` +modules// +├── .module.ts # Module 声明 +├── .controller.ts # RESTful 端点 +├── .service.ts # 业务逻辑 +└── dto/ + ├── create-.dto.ts + └── update-.dto.ts +``` + +- DTO 属性使用 `!` 声明确定赋值(class-validator 负责运行时校验) +- PrismaService 通过 @Global() PrismaModule 注入,无需各模块重复导入 +- 状态变更使用独立端点 `PATCH /:id/status`,与通用 PATCH 分离 + +### 前端开发模式 + +- 页面组件统一标记 `'use client'`(管理后台不使用 SSR) +- Store 通过 `lib/api.ts` 封装的 fetch 与后端通信 +- 组件按功能域分组在 `components//` 下 + +## Environment Variables + +项目根目录提供 `.env.example`,开发者复制为 `.env.local` 使用。 + +```bash +# 数据库 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ftb_pm + +# 认证 +NEXTAUTH_SECRET=your-random-secret-key +NEXTAUTH_URL=http://localhost:3000 + +# AI +ANTHROPIC_API_KEY=sk-ant-xxx + +# Redis(Socket.io 适配器 + 缓存) +REDIS_URL=redis://localhost:6379 + +# 邮件通知(可选) +SMTP_HOST=smtp.example.com +SMTP_PORT=465 +SMTP_USER=noreply@example.com +SMTP_PASS=your-smtp-password +``` + +## Deployment + +采用 Docker Compose 部署到云服务器(推荐 2核4G),一套配置本地和线上通用。 + +```yaml +# docker-compose.prod.yml 核心服务 +services: + web: # Next.js 前端,端口 3000 + server: # NestJS 后端,端口 3001 + postgres: # PostgreSQL 数据库,端口 5432 + redis: # Redis 缓存 + Socket.io,端口 6379 + nginx: # 反向代理 + SSL 终止,端口 80/443 +``` + +部署流程: +1. 服务器安装 Docker + Docker Compose +2. 配置 `.env.production` 环境变量 +3. `docker-compose -f docker-compose.prod.yml up -d` +4. Nginx 配置域名 + Let's Encrypt SSL 证书 +5. 数据库迁移:`docker exec server pnpm db:migrate` diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index 51962a0..b86f9a8 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -18,7 +18,7 @@ import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/ve import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; -import type { VersionPlan } from '@/lib/version-plan'; +import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan'; import type { DevTask } from '@/lib/dev-task'; import type { TestCase } from '@/lib/test-case'; import type { Bug } from '@/lib/bug'; @@ -139,13 +139,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ if (p.status === 'completed') doneItems += count; else doneItems += tasks.filter((t) => t.status === 'completed').length; } else { - const linked = p.linkedRequirementIds || []; - const count = Math.max(linked.length, 1); + const summary = getRequirementCoverageSummary(p); + const count = Math.max(summary.total, 1); totalItems += count; if (p.status === 'completed') doneItems += count; else { - const completed = p.completedRequirementIds || []; - doneItems += completed.filter((id) => linked.includes(id)).length; + doneItems += summary.completed; } } } @@ -411,10 +410,9 @@ export default function ProjectDetailPage() { const productPlans = vPlans.filter((p) => p.type === 'product'); if (productPlans.length > 0) { const totals = productPlans.reduce((acc, p) => { - const linked = p.linkedRequirementIds || []; - const completed = p.completedRequirementIds || []; - acc.total += linked.length; - acc.done += completed.filter((id) => linked.includes(id)).length; + const summary = getRequirementCoverageSummary(p); + acc.total += summary.total; + acc.done += summary.completed; return acc; }, { total: 0, done: 0 }); segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0); @@ -423,10 +421,9 @@ export default function ProjectDetailPage() { const uiPlans = vPlans.filter((p) => p.type === 'ui'); if (uiPlans.length > 0) { const totals = uiPlans.reduce((acc, p) => { - const linked = p.linkedRequirementIds || []; - const completed = p.completedRequirementIds || []; - acc.total += linked.length; - acc.done += completed.filter((id) => linked.includes(id)).length; + const summary = getRequirementCoverageSummary(p); + acc.total += summary.total; + acc.done += summary.completed; return acc; }, { total: 0, done: 0 }); segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0); diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 4eb57fb..7b305b3 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -32,6 +32,7 @@ import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-select import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview'; import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation'; +import { getRequirementCoverageSummary } from '@/lib/version-plan'; function formatOverviewDateTime(value?: string | null): string { if (!value) return '-'; @@ -369,14 +370,13 @@ export default function VersionDetailPage() { doneItems += tasks.filter((t) => t.status === 'completed').length; } } else { - const linked = p.linkedRequirementIds || []; - const count = Math.max(linked.length, 1); + const summary = getRequirementCoverageSummary(p); + const count = Math.max(summary.total, 1); totalItems += count; if (p.status === 'completed') { doneItems += count; } else { - const completed = p.completedRequirementIds || []; - doneItems += completed.filter((id) => linked.includes(id)).length; + doneItems += summary.completed; } } } diff --git a/apps/web/components/ActivityLogPanel.tsx b/apps/web/components/ActivityLogPanel.tsx new file mode 100644 index 0000000..fd878cf --- /dev/null +++ b/apps/web/components/ActivityLogPanel.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { formatDateTime } from '@/lib/format'; +import type { WorkActivitySourceType } from '@/lib/work-activity'; +import { getEntityActivityLogEntries, type EntityActivityLogEntry } from '@/lib/entity-activity-log'; +import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; + +interface Props { + sourceType: WorkActivitySourceType; + sourceId: string; + legacyEntries?: EntityActivityLogEntry[]; + title?: string; +} + +export function ActivityLogPanel({ sourceType, sourceId, legacyEntries = [], title = '操作日志' }: Props) { + const { activities, fetchActivities } = useWorkActivityStore(); + + useEffect(() => { + fetchActivities(); + }, [fetchActivities]); + + const entries = useMemo( + () => getEntityActivityLogEntries(activities, sourceType, sourceId, legacyEntries), + [activities, sourceType, sourceId, legacyEntries], + ); + + return ( +
+
{title}
+ {entries.length === 0 ? ( +

暂无操作日志

+ ) : ( +
+ {entries.map((entry) => ( +
+ {formatDateTime(entry.occurredAt)} +
+ {entry.actorId || '系统'} + {entry.label} +
{entry.summary}
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/bug/BugDetailDrawer.tsx b/apps/web/components/bug/BugDetailDrawer.tsx index 6f88951..fbfd65f 100644 --- a/apps/web/components/bug/BugDetailDrawer.tsx +++ b/apps/web/components/bug/BugDetailDrawer.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react'; import { BugStatusBadge } from './BugStatusBadge'; +import { ActivityLogPanel } from '@/components/ActivityLogPanel'; import { useBugStore } from '@/stores/useBugStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -11,6 +12,7 @@ import { useAuthStore } from '@/stores/useAuthStore'; import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug'; import { formatDateTime } from '@/lib/format'; import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system'; +import type { EntityActivityLogEntry } from '@/lib/entity-activity-log'; import type { BugStatus } from '@/lib/bug'; const LOG_ACTION_LABEL: Record = { @@ -53,6 +55,21 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) { const [transferTo, setTransferTo] = useState(''); const [transferRemark, setTransferRemark] = useState(''); const [lightboxSrc, setLightboxSrc] = useState(null); + const legacyLogEntries = useMemo(() => { + return (bug.logs || []).map((log) => { + const from = log.fromValue ? resolveMemberDisplayName(log.fromValue, members) : ''; + const to = log.toValue ? resolveMemberDisplayName(log.toValue, members) : ''; + const change = from && to ? `${from} → ${to}` : ''; + const remark = log.remark ? `(${log.remark})` : ''; + return { + id: `bug-log-${log.id}`, + occurredAt: log.createdAt, + actorId: resolveMemberDisplayName(log.operator, members), + label: LOG_ACTION_LABEL[log.action] || log.action, + summary: [change, remark].filter(Boolean).join(' ') || bug.title, + }; + }); + }, [bug.logs, bug.title, members]); const handleTransition = (to: BugStatus) => { if (to === 'fixed') { setShowResolutionInput(true); return; } @@ -201,27 +218,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) { )} - {/* 操作日志 */} - {bug.logs && bug.logs.length > 0 && ( -
-
操作日志
-
- {[...bug.logs].reverse().map((log) => ( -
- {formatDateTime(log.createdAt)} -
- {resolveMemberDisplayName(log.operator, members)} - {LOG_ACTION_LABEL[log.action] || log.action} - {log.fromValue && log.toValue && ( - {resolveMemberDisplayName(log.fromValue, members)} → {resolveMemberDisplayName(log.toValue, members)} - )} - {log.remark && ({log.remark})} -
-
- ))} -
-
- )} + diff --git a/apps/web/components/bug/BugRow.tsx b/apps/web/components/bug/BugRow.tsx index 7b68126..2ba6c4b 100644 --- a/apps/web/components/bug/BugRow.tsx +++ b/apps/web/components/bug/BugRow.tsx @@ -27,20 +27,30 @@ function BugRowImpl({ bug, testCaseNo, onClick }: Props) { const members = useMemberStore((s) => s.members); const assigneeName = resolveMemberDisplayName(bug.assigneeId, members); return ( -
- - {bug.bugNo} - {bug.title} - {BUG_SEVERITY_LABEL[bug.severity]} - - - {bug.plannedFixAt ? formatDateTimeShort(bug.plannedFixAt) : '待排期'} - - {actualHours > 0 && ( - {formatWorkHours(actualHours)} - )} - {testCaseNo && {testCaseNo}} - {assigneeName} +
+
+ + {bug.bugNo} +
+
+ {bug.title} + + + {bug.plannedFixAt ? formatDateTimeShort(bug.plannedFixAt) : '待排期'} + + {actualHours > 0 && ( + 实际 {formatWorkHours(actualHours)} + )} + +
+
+ {BUG_SEVERITY_LABEL[bug.severity]} + + {assigneeName} + {testCaseNo && {testCaseNo}} +
+
+
); } diff --git a/apps/web/components/bug/BugStatusBadge.tsx b/apps/web/components/bug/BugStatusBadge.tsx index 8676ccc..f1dbe98 100644 --- a/apps/web/components/bug/BugStatusBadge.tsx +++ b/apps/web/components/bug/BugStatusBadge.tsx @@ -5,7 +5,7 @@ import type { BugStatus } from '@/lib/bug'; export function BugStatusBadge({ status }: { status: BugStatus }) { return ( - + {BUG_STATUS_LABEL[status]} ); diff --git a/apps/web/components/dev-task/CategoryChip.tsx b/apps/web/components/dev-task/CategoryChip.tsx index 72dde82..981ade6 100644 --- a/apps/web/components/dev-task/CategoryChip.tsx +++ b/apps/web/components/dev-task/CategoryChip.tsx @@ -1,13 +1,25 @@ 'use client'; +import type { CSSProperties } from 'react'; import type { TaskCategory } from '@/lib/task-category'; -export function CategoryChip({ category }: { category?: TaskCategory }) { - if (!category) return 未分类; +export function CategoryChip({ category, widthEm }: { category?: TaskCategory; widthEm?: number }) { + const widthStyle: CSSProperties = widthEm ? { width: `${widthEm}em` } : {}; + if (!category) { + return ( + + 未分类 + + ); + } return ( s.addProgressNote); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); + const user = useAuthStore((s) => s.user); const [showTransfer, setShowTransfer] = useState(false); const [transferTo, setTransferTo] = useState(''); const [showDelayInput, setShowDelayInput] = useState(false); const [delayReason, setDelayReason] = useState(''); + const [showPlanInput, setShowPlanInput] = useState(false); + const [planStartLocal, setPlanStartLocal] = useState(''); + const [planEndLocal, setPlanEndLocal] = useState(''); const [progressNote, setProgressNote] = useState(''); const [progressBlocker, setProgressBlocker] = useState(''); const [progressHelperId, setProgressHelperId] = useState(''); @@ -61,8 +82,42 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const actual = useMemo(() => getActualHours(task), [task]); const overrun = actual > estimate && estimate > 0; const requireDelay = task.status === 'todo' && needsDelayReason(task); + const currentUserName = user?.name || ''; + const needsClaim = needsDevTaskClaim(task); + const startReady = canStartDevTask(task); + const visibleNextStatuses = nextStatuses.filter((status) => status !== 'in_progress' || startReady); + const planStartISO = localToISO(planStartLocal); + const planEndISO = localToISO(planEndLocal); + const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO); + const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0; + + const openPlanInput = () => { + setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal()); + setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal()); + setShowPlanInput(true); + }; + + const handleSavePlan = () => { + const assigneeId = task.assigneeId || currentUserName; + if (!assigneeId) { + alert('领取前需要先登录或选择负责人'); + return; + } + if (!planStartBeforeEnd || planEstimateHours <= 0 || !planStartISO || !planEndISO) return; + updateTask(task.id, { + assigneeId, + expectedStartAt: planStartISO, + expectedEndAt: planEndISO, + estimateHours: planEstimateHours, + }); + setShowPlanInput(false); + }; const handleTransition = (to: DevTaskStatus) => { + if (to === 'in_progress' && !startReady) { + openPlanInput(); + return; + } if (to === 'in_progress' && requireDelay && !showDelayInput) { setShowDelayInput(true); return; @@ -179,10 +234,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel )}
- {nextStatuses.length > 0 && !showDelayInput && ( + {visibleNextStatuses.length > 0 && !showDelayInput && (
- {nextStatuses.map((s) => ( + {visibleNextStatuses.map((s) => ( @@ -190,6 +245,61 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
)} + {task.status === 'todo' && !startReady && !showPlanInput && ( +
+ + + {needsClaim ? '领取时必须填写预计开始和预计截止' : '开始开发前需要补齐预计开始和预计截止'} + +
+ )} + + {showPlanInput && ( +
+
+ {needsClaim ? '领取并填写计划' : '填写计划'} +
+
+
+ + +
+
+ + +
+
+
+ + 执行预估:{planEstimateHours > 0 ? formatHours(planEstimateHours) : '请选择有效起止时间'} + +
+ + +
+
+
+ )} + {showDelayInput && (
@@ -332,7 +442,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
负责人 - {task.assigneeId} + {needsClaim ? '待领取' : task.assigneeId}
优先级 @@ -351,6 +461,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel )}
+ + {predecessors.length > 0 && (
前置任务
diff --git a/apps/web/components/dev-task/DevTaskRow.tsx b/apps/web/components/dev-task/DevTaskRow.tsx index 85b991d..ae7d6c3 100644 --- a/apps/web/components/dev-task/DevTaskRow.tsx +++ b/apps/web/components/dev-task/DevTaskRow.tsx @@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; -import { getEstimateHours, getActualHours } from '@/lib/dev-task'; +import { getEstimateHours, getActualHours, needsDevTaskClaim } from '@/lib/dev-task'; import { formatShortTime, formatWorkHours } from '@/lib/work-hours'; import type { DevTask } from '@/lib/dev-task'; import type { TaskCategory } from '@/lib/task-category'; @@ -11,6 +11,7 @@ import type { TaskCategory } from '@/lib/task-category'; interface Props { task: DevTask; category?: TaskCategory; + categoryLabelWidthEm?: number; onClick?: () => void; } @@ -45,37 +46,64 @@ function hoursText(task: DevTask, estimate: number, actual: number): { text: str return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone }; } -export function DevTaskRow({ task, category, onClick }: Props) { +export function DevTaskRow({ task, category, categoryLabelWidthEm, onClick }: Props) { const estimate = getEstimateHours(task); const actual = getActualHours(task); const range = timeRangeText(task); const hours = hoursText(task, estimate, actual); + const needsClaim = needsDevTaskClaim(task); + const labelWidthStyle = categoryLabelWidthEm ? { width: `${categoryLabelWidthEm}em` } : undefined; return (
- - {task.taskNo} -
- {task.title} - {task.aiDraft && ( - - AI 草案 - - )} - {task.isBlocked && ( - - 阻塞 - - )} +
+ + {task.taskNo} +
+
+ {task.title} + + {range.text && ( + {range.text} + )} + {hours.text} + +
+
+ {task.isBlocked && ( + + 阻塞 + + )} + {task.aiDraft && ( + + AI 草案 + + )} + {!needsClaim && ( + 负责人:{task.assigneeId} + )} + {needsClaim ? ( + + 待领取 + + ) : ( + + )} + +
+
- - - {range.text} - {hours.text} - {task.assigneeId}
); } diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index c8fe2e8..aa2742e 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -111,6 +111,10 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props }, [paged]); const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); + const categoryLabelWidthEm = useMemo( + () => Math.max(4, ...categories.map((category) => category.name.length)) + 1, + [categories], + ); const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]); @@ -179,11 +183,16 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props const reqProgress = calcGroupProgress(reqTasks); return (
-
- selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" /> - {req?.code} - {req?.title} - {reqProgress}% +
+
+ selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" /> +
+
+ + {req?.code} + {req?.title} + {reqProgress}% +
{reqTasks.map((t) => (
@@ -191,7 +200,7 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
- setSelectedTaskId(t.id)} /> + setSelectedTaskId(t.id)} />
))} diff --git a/apps/web/components/dev-task/StatusBadge.tsx b/apps/web/components/dev-task/StatusBadge.tsx index fb2ea64..7159c41 100644 --- a/apps/web/components/dev-task/StatusBadge.tsx +++ b/apps/web/components/dev-task/StatusBadge.tsx @@ -3,9 +3,12 @@ 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 }) { +export function StatusBadge({ status, widthEm }: { status: DevTaskStatus; widthEm?: number }) { return ( - + {DEV_TASK_STATUS_LABEL[status]} ); diff --git a/apps/web/components/test-case/TestCaseCreateModal.tsx b/apps/web/components/test-case/TestCaseCreateModal.tsx index d1b8497..e675ce1 100644 --- a/apps/web/components/test-case/TestCaseCreateModal.tsx +++ b/apps/web/components/test-case/TestCaseCreateModal.tsx @@ -26,6 +26,12 @@ function defaultPlannedTestLocal(): string { return isoToLocal(d.toISOString()); } +function defaultPlannedEndLocal(): string { + const d = new Date(); + d.setHours(10, 0, 0, 0); + return isoToLocal(d.toISOString()); +} + export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) { const { createTestCase } = useTestCaseStore(); const { requirements } = useRequirementStore(); @@ -49,6 +55,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos ); const [estimateHours, setEstimateHours] = useState(0.5); const [plannedTestLocal, setPlannedTestLocal] = useState(defaultPlannedTestLocal); + const [plannedEndLocal, setPlannedEndLocal] = useState(defaultPlannedEndLocal); const [assigneeId, setAssigneeId] = useState(user?.name || ''); const [description, setDescription] = useState(''); const [prototypeNotes, setPrototypeNotes] = useState(''); @@ -69,7 +76,9 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours); const plannedTestAt = localToISO(plannedTestLocal); - const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && Boolean(plannedTestAt); + const plannedEndAt = localToISO(plannedEndLocal); + const hasValidPlan = Boolean(plannedTestAt && plannedEndAt && plannedEndAt > plannedTestAt); + const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && hasValidPlan; const handleSubmit = () => { if (!canSubmit) return; @@ -92,6 +101,7 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos priority, estimateHours: normalizedEstimateHours, plannedTestAt, + plannedEndAt, assigneeId: assigneeId || undefined, references: references.length > 0 ? references : undefined, createdBy: user?.name || '系统', @@ -154,15 +164,32 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
-
- - +
+
+ + +
+
+ + +
+ {!hasValidPlan && plannedTestLocal && plannedEndLocal && ( +
+ 计划开始必须早于计划结束 +
+ )}