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/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 2502f10..1aade80 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from 'react'; import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, ArrowRightLeft, CalendarRange } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; +import { ActivityLogPanel } from '@/components/ActivityLogPanel'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; @@ -460,6 +461,8 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel )} + + {predecessors.length > 0 && (
前置任务
diff --git a/apps/web/components/test-case/TestCaseDetailDrawer.tsx b/apps/web/components/test-case/TestCaseDetailDrawer.tsx index b60b3e4..9400194 100644 --- a/apps/web/components/test-case/TestCaseDetailDrawer.tsx +++ b/apps/web/components/test-case/TestCaseDetailDrawer.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2, ArrowRightLeft } from 'lucide-react'; import { TestCaseStatusBadge } from './TestCaseStatusBadge'; import { BugStatusBadge } from '@/components/bug/BugStatusBadge'; +import { ActivityLogPanel } from '@/components/ActivityLogPanel'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -314,6 +315,8 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
)} + + diff --git a/apps/web/components/version/AiDecomposeButton.tsx b/apps/web/components/version/AiDecomposeButton.tsx index 2a518cd..5c317ac 100644 --- a/apps/web/components/version/AiDecomposeButton.tsx +++ b/apps/web/components/version/AiDecomposeButton.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react'; +import { appendPlanLog } from '@/lib/version-plan'; import type { VersionPlan } from '@/lib/version-plan'; import type { VersionWithContext } from '@/lib/derive'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; @@ -102,6 +103,23 @@ export function AiDecomposeButton({ plan, version }: Props) { return '任务和用例'; }; + const appendAiLog = ( + target: AgentDecomposeTarget, + status: 'started' | 'completed' | 'error', + detail?: string, + ) => { + const currentPlan = useVersionPlanStore.getState().plans.find((item) => item.id === plan.id) ?? plan; + const statusText = status === 'started' ? '已触发' : status === 'completed' ? '已完成' : '失败'; + return appendPlanLog(currentPlan, { + type: 'ai_decompose', + actor: user?.name ?? plan.owner, + title: `AI 拆解${targetText(target)}${statusText}`, + detail, + aiTarget: target, + aiStatus: status, + }); + }; + const handleClick = async (target: AgentDecomposeTarget) => { if (loading) return; // 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点 @@ -117,6 +135,7 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeAt: new Date().toISOString(), aiDecomposeTarget: target, aiDecomposeError: undefined, + logs: appendAiLog(target, 'started'), }); const members = (version.members ?? []).map((m) => ({ @@ -144,6 +163,7 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeStatus: 'error', aiDecomposeTarget: target, aiDecomposeError: resp.error, + logs: appendAiLog(target, 'error', resp.error), }); } else { const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target); @@ -166,6 +186,11 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeStatus: 'completed', aiDecomposeTarget: target, aiDecomposeError: undefined, + logs: appendAiLog( + target, + 'completed', + `生成开发任务 ${deduped.result.devTaskDrafts.length} 条,测试用例 ${deduped.result.testCaseDrafts.length} 条。已过滤重复开发任务 ${deduped.removedDevTaskCount} 条,重复测试用例 ${deduped.removedTestCaseCount} 条。`, + ), }); } } catch (e: any) { @@ -174,6 +199,7 @@ export function AiDecomposeButton({ plan, version }: Props) { aiDecomposeStatus: 'error', aiDecomposeTarget: target, aiDecomposeError: msg, + logs: appendAiLog(target, 'error', msg), }); } finally { setActiveTarget(null); diff --git a/apps/web/components/version/PlanDetailDrawer.tsx b/apps/web/components/version/PlanDetailDrawer.tsx index 599650d..55a78b7 100644 --- a/apps/web/components/version/PlanDetailDrawer.tsx +++ b/apps/web/components/version/PlanDetailDrawer.tsx @@ -5,9 +5,10 @@ import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'luc import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; +import { useAuthStore } from '@/stores/useAuthStore'; import { calcPlanProgress, - calcLinkedReqProgress, + getRequirementCoverageSummary, PRODUCT_PLAN_KIND_LABEL, PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS, PRODUCT_PLAN_REVIEW_RESULT_LABEL, @@ -16,6 +17,7 @@ import { formatDateTime } from '@/lib/format'; import type { PlanTask, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan'; import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow'; import type { PlanResultPayload } from '@/lib/version-plan-workflow'; +import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel'; interface Props { planId: string; @@ -49,6 +51,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { const { plans, updatePlan, completePlan } = useVersionPlanStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); + const user = useAuthStore((s) => s.user); const [showTransfer, setShowTransfer] = useState(false); const [transferTo, setTransferTo] = useState(''); const [resultType, setResultType] = useState<'link' | 'file'>('link'); @@ -67,10 +70,11 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { const completionState = getPlanCompletionState(plan); const isResearch = plan.type === 'research'; - const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds); + const progress = isResearch ? calcPlanProgress(plan.tasks) : getRequirementCoverageSummary(plan).percent; const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[]; const canToggle = canTogglePlanChecklist(plan); const canEditCoverage = canEditPlanRequirementCoverage(plan); + const currentUserName = user?.name ?? plan.owner; const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined; const isProductDesignPlan = productPlanKind === 'design'; const isProductReviewPlan = productPlanKind === 'review'; @@ -82,13 +86,6 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { updatePlan(plan.id, { tasks: updatedTasks }); }; - const handleToggleReq = (reqId: string) => { - if (!canEditCoverage) return; - const current = plan.completedRequirementIds || []; - const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId]; - updatePlan(plan.id, { completedRequirementIds: next }); - }; - const handleFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -235,32 +232,25 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) { )} - {/* Product/UI: Linked Requirements */} {linkedReqs.length > 0 && ( -
-
关联需求
- {linkedReqs.map((req) => { - const isDone = (plan.completedRequirementIds || []).includes(req.id); - return ( -
- - {req.code} - {req.title} -
- ); - })} +
+ {plan.status === 'in_progress' && !completionState.canSubmitResult && (

还不能提交成果:{completionState.missingReasons.join('、')}

)}
)} + {!isResearch && ( + + )} + {/* Result */} {plan.status === 'completed' && plan.resultUrl && (
diff --git a/apps/web/components/version/PlanRequirementCoveragePanel.tsx b/apps/web/components/version/PlanRequirementCoveragePanel.tsx new file mode 100644 index 0000000..36ceb85 --- /dev/null +++ b/apps/web/components/version/PlanRequirementCoveragePanel.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useState } from 'react'; +import { Check, Clock3, Sparkles } from 'lucide-react'; +import { formatDateTime } from '@/lib/format'; +import type { Requirement } from '@/lib/requirement'; +import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan'; +import { + getRequirementCoverage, + getRequirementCoverageStatus, + getRequirementCoverageSummary, + REQUIREMENT_COVERAGE_LABEL, + updateRequirementCoverage, +} from '@/lib/version-plan'; + +type RequirementOption = Pick & { isHistorical?: boolean }; + +interface CoverageProps { + plan: VersionPlan; + requirements: RequirementOption[]; + canEdit: boolean; + currentUserName: string; + onUpdate: (id: string, data: Partial) => void; +} + +interface LogTimelineProps { + logs?: VersionPlanLog[]; + className?: string; +} + +const COVERAGE_STATUS_OPTIONS: RequirementCoverageStatus[] = ['partial', 'completed', 'not_started']; + +const COVERAGE_BADGE_STYLE: Record = { + not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500', + partial: 'border-amber-200 bg-amber-50 text-amber-700', + completed: 'border-emerald-200 bg-emerald-50 text-emerald-700', +}; + +function getLogIcon(log: VersionPlanLog) { + if (log.type === 'ai_decompose') return ; + if (log.type === 'requirement_progress') return ; + return ; +} + +function getLogTone(log: VersionPlanLog): string { + if (log.aiStatus === 'error') return 'bg-red-50 text-red-700 ring-red-100'; + if (log.type === 'ai_decompose') return 'bg-purple-50 text-purple-700 ring-purple-100'; + if (log.coverageStatus === 'completed') return 'bg-emerald-50 text-emerald-700 ring-emerald-100'; + if (log.coverageStatus === 'partial') return 'bg-amber-50 text-amber-700 ring-amber-100'; + return 'bg-zinc-50 text-zinc-600 ring-zinc-100'; +} + +export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) { + const [editingRequirementId, setEditingRequirementId] = useState(null); + const [draftStatus, setDraftStatus] = useState('partial'); + const [completedContent, setCompletedContent] = useState(''); + const [remainingContent, setRemainingContent] = useState(''); + const summary = getRequirementCoverageSummary(plan); + + if (requirements.length === 0) return null; + + const openEditor = (req: RequirementOption) => { + const coverage = getRequirementCoverage(plan, req.id); + setEditingRequirementId(req.id); + setDraftStatus(coverage?.status === 'completed' ? 'completed' : coverage?.status === 'not_started' ? 'not_started' : 'partial'); + setCompletedContent(coverage?.completedContent ?? ''); + setRemainingContent(coverage?.remainingContent ?? ''); + }; + + const closeEditor = () => { + setEditingRequirementId(null); + setCompletedContent(''); + setRemainingContent(''); + setDraftStatus('partial'); + }; + + const canSave = draftStatus === 'not_started' + || (draftStatus === 'completed' && completedContent.trim().length > 0) + || (draftStatus === 'partial' && completedContent.trim().length > 0 && remainingContent.trim().length > 0); + + const saveCoverage = (req: RequirementOption) => { + if (!canEdit || !canSave) return; + const patch = updateRequirementCoverage(plan, { + requirementId: req.id, + status: draftStatus, + completedContent: draftStatus === 'not_started' ? undefined : completedContent, + remainingContent: draftStatus === 'partial' ? remainingContent : undefined, + updatedBy: currentUserName, + requirementCode: req.code, + requirementTitle: req.title, + }); + onUpdate(plan.id, patch); + closeEditor(); + }; + + return ( +
+
+
+
引用需求
+
+ 完全完成 {summary.completed} / {summary.total} + {summary.partial > 0 && 部分完成 {summary.partial}} +
+
+ {summary.percent}% +
+
+
+
+
+
+
+ {requirements.map((req) => { + const status = getRequirementCoverageStatus(plan, req.id); + const coverage = getRequirementCoverage(plan, req.id); + const isEditing = editingRequirementId === req.id; + return ( +
+
+ + {REQUIREMENT_COVERAGE_LABEL[status]} + +
+
+ {req.code} + {req.title} + {req.isHistorical && 历史} +
+ {(coverage?.completedContent || coverage?.remainingContent) && ( +
+ {coverage.completedContent &&
已完成:{coverage.completedContent}
} + {coverage.remainingContent &&
剩余:{coverage.remainingContent}
} +
+ )} +
+ {canEdit && ( + + )} +
+ {isEditing && ( +
+
+ {COVERAGE_STATUS_OPTIONS.map((statusOption) => ( + + ))} +
+ {draftStatus !== 'not_started' && ( +