From 1319677c84be749bf1557f9add7e1d29d0bb5a89 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Fri, 26 Jun 2026 16:41:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs(workspace):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B4=BB=E5=8A=A8=E6=97=A5=E6=8A=A5=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-26-work-activity-daily-report.md | 143 ++++++++++++++++++ ...06-26-work-activity-daily-report-design.md | 87 +++++++++++ 2 files changed, 230 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-26-work-activity-daily-report.md create mode 100644 docs/superpowers/specs/2026-06-26-work-activity-daily-report-design.md diff --git a/docs/superpowers/plans/2026-06-26-work-activity-daily-report.md b/docs/superpowers/plans/2026-06-26-work-activity-daily-report.md new file mode 100644 index 0000000..021f1ad --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-work-activity-daily-report.md @@ -0,0 +1,143 @@ +# Work Activity Daily Report Implementation Plan + +> **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:** Add a lightweight work-activity layer and use it to build a more complete personal daily report. + +**Architecture:** Add `WorkActivity` types, a Zustand store persisted through the existing `app_data` document API, and a pure `workspace-daily-report` aggregator that combines activities, legacy worklogs, and current workspace items. Store business actions create activity records at existing operation entry points. + +**Tech Stack:** Next.js app router, Zustand stores, TypeScript pure functions, Node test runner through the existing web test command. + +--- + +### Task 1: Activity Types And Daily Report Aggregation + +**Files:** +- Create: `apps/web/lib/work-activity.ts` +- Modify: `apps/web/lib/workspace-daily-report.ts` +- Modify: `apps/web/lib/workspace-daily-report.test.ts` + +- [ ] **Step 1: Write failing tests** + +Add tests that call `getWorkspaceDailyReport` with `activities`, `worklogs`, `workItems`, `userId`, and `date`. Cover activity grouping, user/date filtering, old worklog compatibility, and needs-progress detection for an in-progress item without today's note. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `pnpm --filter=web test -- workspace-daily-report` + +Expected: FAIL because `activities` and `needsProgressItems` are not implemented. + +- [ ] **Step 3: Implement minimal types and aggregation** + +Create `WorkActivity` and update `WorkspaceDailyReport` with `groups`, `legacyWorklogs`, and `needsProgressItems`. Keep `items` as a compatibility alias for old worklog-derived rows until the UI is updated. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run: `pnpm --filter=web test -- workspace-daily-report` + +Expected: PASS. + +### Task 2: Activity Persistence Store + +**Files:** +- Create: `apps/web/stores/useWorkActivityStore.ts` +- Modify: `apps/web/lib/server-data.ts` +- Modify: `apps/server/src/modules/data/data-keys.ts` + +- [ ] **Step 1: Write failing persistence/type test** + +Extend an existing data-key or server-data test so `work-activities` is accepted. + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `pnpm --filter=web test -- workspace-daily-report` + +Expected: FAIL or type-check failure until the new key is registered. + +- [ ] **Step 3: Implement store and data key** + +Add `useWorkActivityStore` with `fetchActivities`, `addActivity`, and `addProgressNote`, persisted through `saveServerData('work-activities', items)`. + +- [ ] **Step 4: Run focused tests** + +Run: `pnpm --filter=web test -- workspace-daily-report` + +Expected: PASS. + +### Task 3: Emit Activities From Business Stores + +**Files:** +- Modify: `apps/web/stores/useVersionPlanStore.ts` +- Modify: `apps/web/stores/useDevTaskStore.ts` +- Modify: `apps/web/stores/useBugStore.ts` +- Modify if present: `apps/web/stores/useTestCaseStore.ts` + +- [ ] **Step 1: Add tests for action-to-activity mapping where pure helpers exist** + +Cover dev-task start/submitted, plan completion, and bug fixed/closed through helper-level tests or store-light tests if existing patterns allow. + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pnpm --filter=web test -- workspace-daily-report dev-task-workflow bug-workflow version-plan-workflow` + +Expected: FAIL for new activity mapping expectations. + +- [ ] **Step 3: Emit activities in store methods** + +Call `useWorkActivityStore.getState().addActivity(...)` after successful create, status transition, completion, blocked change, transfer, and progress-note actions. + +- [ ] **Step 4: Run tests** + +Run: `pnpm --filter=web test -- workspace-daily-report dev-task-workflow bug-workflow version-plan-workflow` + +Expected: PASS. + +### Task 4: Workspace And Drawer UI + +**Files:** +- Modify: `apps/web/app/workspace/page.tsx` +- Modify: `apps/web/components/workspace/DailyReportPanel.tsx` +- Modify: `apps/web/components/dev-task/DevTaskDetailDrawer.tsx` + +- [ ] **Step 1: Write/update UI-adjacent tests if existing component tests cover the panel** + +Assert grouped labels render from the daily report model when component tests are available. + +- [ ] **Step 2: Wire the workspace page** + +Fetch `work-activities`, pass them into `getWorkspaceDailyReport`, and render grouped sections. + +- [ ] **Step 3: Add progress-note controls in dev-task drawer** + +Expose a compact form for ongoing work that records note, blocker/help/risk metadata through `addProgressNote`. + +- [ ] **Step 4: Run web tests and type-check** + +Run: `pnpm --filter=web test` + +Run: `pnpm --filter=web type-check` + +Expected: PASS. + +### Task 5: Documentation And Final Verification + +**Files:** +- Modify: `docs/architecture.md` +- Modify: `docs/decisions.md` +- Modify: `docs/roadmap.md` + +- [ ] **Step 1: Document the new activity layer** + +Add a short architecture note and decision explaining why this is an activity engine instead of a generic rules engine. + +- [ ] **Step 2: Run full verification** + +Run: `pnpm --filter=web test` + +Run: `pnpm --filter=web type-check` + +Run: `pnpm --filter=server test` + +Run: `git diff --check` + +Expected: all commands exit 0. diff --git a/docs/superpowers/specs/2026-06-26-work-activity-daily-report-design.md b/docs/superpowers/specs/2026-06-26-work-activity-daily-report-design.md new file mode 100644 index 0000000..a4c22ca --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-work-activity-daily-report-design.md @@ -0,0 +1,87 @@ +# Work Activity Daily Report Design + +## Goal + +Build a lightweight activity-record layer so the workspace daily report can show what the current user actually did today, including automatic business actions and manual progress notes. + +## Decision + +Use a `work-activities` event log plus a pure daily-report aggregation function. Do not add a generic rules engine or scheduler for the MVP. + +Automatic events prove that an action happened today. Manual progress notes explain ongoing work that did not change status today. + +## Scope + +In scope: + +- Current `/workspace` daily report remains personal and filters by the logged-in user. +- Activity records are stored in the server `app_data` document layer under `work-activities`. +- Product plan, dev task, test case, and bug actions can create activity records. +- Daily report groups activity into delivery, progress, creation, risk, and progress-note-needed sections. +- Multi-day in-progress items without today's manual progress note are shown as needing progress update. + +Out of scope: + +- Project-owner and management summary pages. +- Scheduled reminders or fixed daily snapshots. +- Generic configurable rule-engine UI. +- Backend relational table migration. + +## Data Model + +`WorkActivity` is an append-only event-like record: + +- `id`: stable client-generated id. +- `actorId`: current logged-in member display identity. +- `date`: local date, `YYYY-MM-DD`. +- `occurredAt`: ISO timestamp. +- `sourceType`: `version_plan`, `dev_task`, `test_case`, `bug`, or `manual`. +- `sourceId`: entity id. +- `action`: stable semantic action code. +- `title`: snapshot title for display even if entity later changes. +- `summary`: readable activity summary. +- `category`: `delivery`, `progress`, `creation`, `risk`, or `note`. +- `metadata`: optional details such as from/to status, blocker, helper, risk, or note. + +## Capture Points + +Activity creation should happen at business operation entry points, not scattered through page rendering: + +- `useVersionPlanStore`: create plan, start plan, complete plan. +- `useDevTaskStore`: create task, change status, set/clear blocked, add progress note. +- `useBugStore`: create bug, status transition, transfer bug. +- `useTestCaseStore`: create case and status transition when the store already has the action entry. + +This keeps UI components as consumers of store actions and keeps the rules close to existing workflow helpers. + +## Daily Report Rules + +For a user and local date: + +- Delivery: submitted dev tasks, completed plans, fixed/closed bugs, passed tests. +- Progress: started work, moved status forward, self-test started, bug fixing started, transfers accepted. +- Creation: created plans, dev tasks, test cases, bugs. +- Risk: blocked items, delay risk notes, assistance requests. +- Manual notes: explicit progress notes written by the owner. +- Needs progress: in-progress work owned by the user that started before today and has no activity or manual note today. + +Status changes count as daily evidence. For multi-day work with no status change, the user should add a progress note; otherwise the report flags it as needing an update. + +## UI Behavior + +The daily report panel should show grouped summaries and a compact "needs progress" section. It should not become a management dashboard yet. + +Task detail drawers should expose a small progress-note action for ongoing work: + +- today's progress +- blocker or assistance need +- delay risk + +## Testing + +Add focused unit tests for: + +- activity filtering by user and date +- grouping delivery/progress/risk/note items +- automatic needs-progress detection +- preserving old worklog compatibility during aggregation From 64491789aec3058a677e50c4f854c9ffffdceac9 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Fri, 26 Jun 2026 17:07:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(version):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=8F=82=E4=B8=8E=E4=BA=BA=E5=91=98AI=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/versions/[id]/page.tsx | 267 +++++++++++++- apps/web/lib/member-recommendation.test.ts | 303 ++++++++++++++++ apps/web/lib/member-recommendation.ts | 400 +++++++++++++++++++++ 3 files changed, 964 insertions(+), 6 deletions(-) create mode 100644 apps/web/lib/member-recommendation.test.ts create mode 100644 apps/web/lib/member-recommendation.ts diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index f1a294d..4eb57fb 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, UserPlus, X } from 'lucide-react'; +import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; @@ -23,6 +23,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; +import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task'; import { hasPermission } from '@/lib/permissions'; import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours'; @@ -30,6 +31,7 @@ import { formatDateTime } from '@/lib/format'; import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; 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'; function formatOverviewDateTime(value?: string | null): string { if (!value) return '-'; @@ -43,6 +45,18 @@ const BUG_SEVERITY_SEGMENTS = [ { key: 'trivial', label: '轻微', color: 'bg-zinc-400' }, ] as const; +const RECOMMENDATION_ROLE_LABEL: Record = { + frontend: '前端', + backend: '后端', + testing: '测试', +}; + +const RECOMMENDATION_CONFIDENCE_LABEL = { + high: '高置信', + medium: '中置信', + low: '低置信', +} as const; + const TABS = [ { key: 'overview', label: '概览', permission: null as string | null }, { key: 'requirements', label: '关联需求', permission: 'version.req:view' }, @@ -66,7 +80,8 @@ export default function VersionDetailPage() { const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs, deleteBug } = useBugStore(); const user = useAuthStore((s) => s.user); - const { departments, members: allMembers, roles } = useMemberStore(); + const { departments, members: allMembers, roles, fetchMembers } = useMemberStore(); + const { categories: taskCategories, fetchCategories } = useTaskCategoryStore(); const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); const memberCandidates = useMemo( () => allMembers.map((member) => ({ @@ -87,6 +102,8 @@ export default function VersionDetailPage() { } }, [visibleTabs, activeTab]); const [showMemberModal, setShowMemberModal] = useState(false); + const [showRecommendModal, setShowRecommendModal] = useState(false); + const [recommendationDataReady, setRecommendationDataReady] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); @@ -95,6 +112,14 @@ export default function VersionDetailPage() { useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchBugs(); }, [fetchBugs]); + useEffect(() => { + let active = true; + setRecommendationDataReady(false); + Promise.all([Promise.resolve(fetchMembers()), Promise.resolve(fetchCategories())]).finally(() => { + if (active) setRecommendationDataReady(true); + }); + return () => { active = false; }; + }, [fetchMembers, fetchCategories]); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); @@ -144,6 +169,36 @@ export default function VersionDetailPage() { const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); const healthLevel = getHealthLevel(healthScore); const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members); + const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id); + const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id)); + const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId)); + const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id); + const currentSystemParticipation = new Map(); + overview.forEach((product) => { + product.versions.forEach((item) => { + const uniqueNames = new Set((item.members ?? []).map((member) => member.name)); + uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1)); + }); + }); + const memberRecommendationGroups = recommendVersionMembers({ + candidates: memberCandidates, + currentMembers: version.members ?? [], + devTasks, + testCases, + bugs, + overtimeRecords: records, + versionDeadline: version.expectedReleaseDate, + scopeRequirements: recommendationVersionReqs, + scopeDevTasks: recommendationVersionDevTasks, + scopeTestCases: recommendationVersionTestCases, + taskCategories, + historicalStats: memberCandidates.flatMap((member) => { + const projectParticipationCount = currentSystemParticipation.get(member.name); + return typeof projectParticipationCount === 'number' + ? [{ name: member.name, projectParticipationCount }] + : []; + }), + }); const renderActions = () => { const buttons: { label: string; action: () => void; danger?: boolean }[] = []; @@ -455,11 +510,20 @@ export default function VersionDetailPage() { })()}
-
+
参与人员
- +
+ + +
{(() => { const membersList = version.members ?? []; @@ -884,6 +948,20 @@ export default function VersionDetailPage() { )}
+ {showRecommendModal && ( + { + const recommendations = memberRecommendationGroups.flatMap((group) => group.items); + const newMembers = addRecommendedVersionMembers(version.members ?? [], selectedNames, recommendations); + updateVersion(version.productId, version.id, { members: newMembers }); + setShowRecommendModal(false); + }} + onClose={() => setShowRecommendModal(false)} + /> + )} + {/* 参与人员设置弹窗 */} {showMemberModal && ( = 80) return 'bg-emerald-50 text-emerald-700 border-emerald-100'; + if (score >= 65) return 'bg-blue-50 text-blue-700 border-blue-100'; + if (score >= 50) return 'bg-amber-50 text-amber-700 border-amber-100'; + return 'bg-red-50 text-red-700 border-red-100'; +} + +function getRecommendationBarClass(score: number): string { + if (score >= 80) return 'bg-emerald-500'; + if (score >= 65) return 'bg-blue-500'; + if (score >= 50) return 'bg-amber-500'; + return 'bg-red-500'; +} + +function getInitialRecommendationSelection(groups: MemberRecommendationGroup[]): Set { + return new Set(getDefaultRecommendedMemberNames(groups)); +} + +function MemberRecommendationModal({ groups, members, onAdd, onClose }: { + groups: MemberRecommendationGroup[]; + members: { role: Role; name: string }[]; + onAdd: (selectedNames: string[]) => void; + onClose: () => void; +}) { + const allItems = groups.flatMap((group) => group.items); + const [selectedNames, setSelectedNames] = useState>(() => getInitialRecommendationSelection(groups)); + const hasHistoricalSignal = allItems.some((item) => item.confidence === 'high'); + + const toggleName = (name: string) => { + setSelectedNames((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const handleAdd = () => { + if (selectedNames.size === 0) return; + onAdd(Array.from(selectedNames)); + }; + + return ( +
+
e.stopPropagation()}> +
+
+
+

AI推荐参与人员

+ {!hasHistoricalSignal && allItems.length > 0 && ( + 历史数据待导入 + )} +
+

+ 当前 {members.length} 人已加入,推荐 {allItems.length} 人,已选 {selectedNames.size} 人 +

+
+ +
+ +
+ {allItems.length === 0 ? ( +
+ +
暂无可推荐人员
+
当前推荐缺口已满足,或暂无符合前端、后端、测试部门且未加入版本的候选人。
+
+ ) : ( +
+ {groups.map((group) => ( +
+
+
+ {RECOMMENDATION_ROLE_LABEL[group.role]} + + 建议 {group.requiredCount} / 已有 {group.currentCount} / 缺 {group.missingCount} + +
+ 范围 {formatRecommendationHours(group.scopeHours)} +
+
+ {group.items.map((item) => { + const selected = selectedNames.has(item.name); + const workloadCount = item.metrics.activeTaskCount + item.metrics.activeBugCount; + const stats = [ + { label: '剩余', value: formatRecommendationHours(item.metrics.remainingHours) }, + { label: '可接入', value: formatAvailableDays(item.metrics.availableInDays) }, + { label: '事项', value: `${workloadCount}项` }, + { label: '加班', value: formatRecommendationHours(item.metrics.recentOvertimeHours) }, + ]; + + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ +
+ +
+ + +
+
+
+
+ ); +} + function MemberSettingModal({ members, allMembers, onSave, onClose }: { members: { role: Role; name: string }[]; allMembers: { id: string; name: string; departmentName?: string }[]; diff --git a/apps/web/lib/member-recommendation.test.ts b/apps/web/lib/member-recommendation.test.ts new file mode 100644 index 0000000..b84d897 --- /dev/null +++ b/apps/web/lib/member-recommendation.test.ts @@ -0,0 +1,303 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers } from './member-recommendation'; +import type { DevTask } from './dev-task'; +import type { Requirement } from './requirement'; +import type { TestCase } from './test-case'; +import { PRESET_CATEGORIES } from './task-category'; + +function devTask(patch: Partial): DevTask { + return { + id: patch.id || 'dev-1', + taskNo: patch.taskNo || 'DEV-001', + requirementId: patch.requirementId || 'req-1', + title: patch.title || 'Dev task', + categoryId: patch.categoryId || 'cat-1', + assigneeId: patch.assigneeId || 'Backend A', + priority: patch.priority || 'P2', + expectedStartAt: patch.expectedStartAt || '2026-06-24T09:00:00', + expectedEndAt: patch.expectedEndAt || '2026-06-24T18:00:00', + estimateHours: patch.estimateHours, + aiEstimateHours: patch.aiEstimateHours, + actualStartAt: patch.actualStartAt, + actualEndAt: patch.actualEndAt, + status: patch.status || 'in_progress', + isBlocked: patch.isBlocked ?? false, + createdBy: patch.createdBy || 'PM', + createdAt: patch.createdAt || '2026-06-23T09:00:00', + updatedAt: patch.updatedAt || '2026-06-24T10:00:00', + }; +} + +function requirement(patch: Partial): Requirement { + return { + id: patch.id || 'req-1', + code: patch.code || 'REQ-001', + title: patch.title || 'Requirement', + description: patch.description || 'Requirement description', + productId: patch.productId || 'prod-1', + projectId: patch.projectId || 'proj-1', + versionId: patch.versionId || 'ver-1', + sourceType: patch.sourceType || 'internal', + sourceTarget: patch.sourceTarget || 'Product', + platforms: patch.platforms || ['web'], + typeId: patch.typeId || 'type-1', + status: patch.status || 'planned', + priority: patch.priority || 'P2', + effort: patch.effort || 'M', + productOwner: patch.productOwner || 'PM', + creator: patch.creator || 'PM', + createdAt: patch.createdAt || '2026-06-23', + }; +} + +function testCase(patch: Partial): TestCase { + return { + id: patch.id || 'tc-1', + caseNo: patch.caseNo || 'TC-001', + versionId: patch.versionId || 'ver-1', + requirementId: patch.requirementId, + title: patch.title || 'Test case', + categoryId: patch.categoryId || 'cat-test-functional', + priority: patch.priority || 'P2', + assigneeId: patch.assigneeId, + status: patch.status || 'pending', + estimateHours: patch.estimateHours, + aiEstimateHours: patch.aiEstimateHours, + startedAt: patch.startedAt, + completedAt: patch.completedAt, + createdBy: patch.createdBy || 'QA', + createdAt: patch.createdAt || '2026-06-23T09:00:00', + updatedAt: patch.updatedAt || '2026-06-24T10:00:00', + }; +} + +test('recommendVersionMembers only recommends frontend backend and testing candidates outside current members', () => { + const groups = recommendVersionMembers({ + candidates: [ + { id: 'm-1', name: 'Frontend', departmentName: '前端组' }, + { id: 'm-2', name: 'Backend', departmentName: '后端组' }, + { id: 'm-3', name: 'Tester', departmentName: '测试组' }, + { id: 'm-4', name: 'Product', departmentName: '产品部' }, + { id: 'm-5', name: 'Designer', departmentName: '设计部' }, + ], + currentMembers: [{ name: 'Frontend', role: 'frontend' }], + devTasks: [], + testCases: [], + bugs: [], + }); + + assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']); + assert.deepEqual(groups.flatMap((group) => group.items.map((item) => item.name)), ['Backend', 'Tester']); +}); + +test('recommendVersionMembers ranks idle people above overloaded people with deadline conflicts', () => { + const groups = recommendVersionMembers({ + candidates: [ + { id: 'm-1', name: 'Backend Busy', departmentName: '后端组' }, + { id: 'm-2', name: 'Backend Idle', departmentName: '后端组' }, + ], + currentMembers: [], + devTasks: [ + devTask({ + assigneeId: 'Backend Busy', + estimateHours: 16, + expectedEndAt: '2026-06-30T18:00:00', + }), + ], + testCases: [], + bugs: [], + versionDeadline: '2026-06-28T18:00:00', + }); + + const backendItems = groups.find((group) => group.role === 'backend')?.items ?? []; + assert.equal(backendItems[0].name, 'Backend Idle'); + assert.equal(backendItems[1].metrics.deadlineConflictCount, 1); + assert.ok(backendItems[1].warnings.some((warning) => warning.includes('截止'))); +}); + +test('recommendVersionMembers uses historical bug rate without punishing missing history', () => { + const groups = recommendVersionMembers({ + candidates: [ + { id: 'm-1', name: 'Stable Dev', departmentName: '后端组' }, + { id: 'm-2', name: 'Risky Dev', departmentName: '后端组' }, + { id: 'm-3', name: 'New Dev', departmentName: '后端组' }, + ], + currentMembers: [], + devTasks: [], + testCases: [], + bugs: [], + historicalStats: [ + { name: 'Stable Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 2 }, + { name: 'Risky Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 14 }, + ], + }); + + const backendItems = groups.find((group) => group.role === 'backend')?.items ?? []; + const stable = backendItems.find((item) => item.name === 'Stable Dev')!; + const risky = backendItems.find((item) => item.name === 'Risky Dev')!; + const newcomer = backendItems.find((item) => item.name === 'New Dev')!; + + assert.ok(stable.score > risky.score); + assert.equal(stable.confidence, 'high'); + assert.equal(newcomer.confidence, 'medium'); + assert.equal(newcomer.metrics.bugRate, undefined); +}); + +test('recommendVersionMembers limits candidates to strict AI-assisted role demand from requirements', () => { + const groups = recommendVersionMembers({ + candidates: [ + { id: 'm-f1', name: 'Frontend A', departmentName: '前端组' }, + { id: 'm-f2', name: 'Frontend B', departmentName: '前端组' }, + { id: 'm-f3', name: 'Frontend C', departmentName: '前端组' }, + { id: 'm-b1', name: 'Backend A', departmentName: '后端组' }, + { id: 'm-b2', name: 'Backend B', departmentName: '后端组' }, + { id: 'm-q1', name: 'QA A', departmentName: '测试组' }, + { id: 'm-q2', name: 'QA B', departmentName: '测试组' }, + ], + currentMembers: [{ name: 'Backend Existing', role: 'backend' }], + devTasks: [], + testCases: [], + bugs: [], + scopeRequirements: [ + requirement({ id: 'req-xl', effort: 'XL' }), + requirement({ id: 'req-l', effort: 'L' }), + requirement({ id: 'req-m', effort: 'M' }), + ], + scopeDevTasks: [], + scopeTestCases: [], + }); + + const frontend = groups.find((group) => group.role === 'frontend')!; + const backend = groups.find((group) => group.role === 'backend')!; + const testing = groups.find((group) => group.role === 'testing')!; + + assert.equal(frontend.requiredCount, 2); + assert.equal(frontend.currentCount, 0); + assert.equal(frontend.missingCount, 2); + assert.equal(frontend.items.length, 2); + assert.equal(backend.requiredCount, 2); + assert.equal(backend.currentCount, 1); + assert.equal(backend.missingCount, 1); + assert.equal(backend.items.length, 1); + assert.equal(testing.requiredCount, 1); + assert.equal(testing.items.length, 1); +}); + +test('recommendVersionMembers omits roles without scoped task demand', () => { + const groups = recommendVersionMembers({ + candidates: [ + { id: 'm-f1', name: 'Frontend A', departmentName: '前端组' }, + { id: 'm-b1', name: 'Backend A', departmentName: '后端组' }, + { id: 'm-q1', name: 'QA A', departmentName: '测试组' }, + ], + currentMembers: [], + devTasks: [], + testCases: [], + bugs: [], + scopeRequirements: [], + scopeDevTasks: [ + devTask({ id: 'dev-backend', categoryId: 'cat-backend-api', estimateHours: 20, assigneeId: 'Someone' }), + ], + scopeTestCases: [ + testCase({ id: 'tc-functional', estimateHours: 6 }), + ], + taskCategories: PRESET_CATEGORIES, + }); + + assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']); + assert.equal(groups.find((group) => group.role === 'backend')?.items.length, 1); + assert.equal(groups.find((group) => group.role === 'testing')?.items.length, 1); +}); + +test('addRecommendedVersionMembers appends selected people with recommended roles once', () => { + const next = addRecommendedVersionMembers( + [ + { name: 'Product Owner', role: 'product' }, + { name: 'Backend Existing', role: 'backend' }, + ], + ['Frontend Candidate', 'Backend Existing', 'QA Candidate'], + [ + { + name: 'Frontend Candidate', + role: 'frontend', + score: 88, + confidence: 'medium', + reasons: [], + warnings: [], + metrics: { + activeTaskCount: 0, + activeBugCount: 0, + deadlineConflictCount: 0, + remainingHours: 0, + recentOvertimeHours: 0, + availableInDays: 0, + }, + }, + { + name: 'Backend Existing', + role: 'backend', + score: 80, + confidence: 'medium', + reasons: [], + warnings: [], + metrics: { + activeTaskCount: 0, + activeBugCount: 0, + deadlineConflictCount: 0, + remainingHours: 0, + recentOvertimeHours: 0, + availableInDays: 0, + }, + }, + { + name: 'QA Candidate', + role: 'testing', + score: 84, + confidence: 'medium', + reasons: [], + warnings: [], + metrics: { + activeTaskCount: 0, + activeBugCount: 0, + deadlineConflictCount: 0, + remainingHours: 0, + recentOvertimeHours: 0, + availableInDays: 0, + }, + }, + ], + ); + + assert.deepEqual(next, [ + { name: 'Product Owner', role: 'product' }, + { name: 'Backend Existing', role: 'backend' }, + { name: 'Frontend Candidate', role: 'frontend' }, + { name: 'QA Candidate', role: 'testing' }, + ]); +}); + +test('getDefaultRecommendedMemberNames selects every displayed recommendation within role gaps', () => { + const groups = recommendVersionMembers({ + candidates: [ + { id: 'm-f1', name: 'Frontend A', departmentName: '前端组' }, + { id: 'm-f2', name: 'Frontend B', departmentName: '前端组' }, + { id: 'm-f3', name: 'Frontend C', departmentName: '前端组' }, + { id: 'm-b1', name: 'Backend A', departmentName: '后端组' }, + { id: 'm-b2', name: 'Backend B', departmentName: '后端组' }, + ], + currentMembers: [], + devTasks: [], + testCases: [], + bugs: [], + scopeRequirements: [ + requirement({ id: 'req-xl', effort: 'XL' }), + requirement({ id: 'req-xl-2', effort: 'XL' }), + ], + scopeDevTasks: [], + scopeTestCases: [], + }); + + assert.deepEqual(getDefaultRecommendedMemberNames(groups), ['Frontend A', 'Frontend B', 'Backend A', 'Backend B']); +}); diff --git a/apps/web/lib/member-recommendation.ts b/apps/web/lib/member-recommendation.ts new file mode 100644 index 0000000..1c2f3f6 --- /dev/null +++ b/apps/web/lib/member-recommendation.ts @@ -0,0 +1,400 @@ +import type { Role } from './stage'; +import type { VersionMember, VersionMemberCandidate } from './version-members'; +import type { DevTask } from './dev-task'; +import { STATUS_PROGRESS, getEstimateHours } from './dev-task'; +import type { TestCase } from './test-case'; +import { TEST_CASE_STATUS_PROGRESS, getTestCaseEstimateHours } from './test-case'; +import type { Bug, BugSeverity } from './bug'; +import type { OvertimeRecord } from './overtime'; +import type { Effort, Requirement } from './requirement'; +import type { TaskCategory } from './task-category'; + +export type RecommendableRole = 'frontend' | 'backend' | 'testing'; +export type RecommendationConfidence = 'high' | 'medium' | 'low'; + +export interface HistoricalMemberStats { + name: string; + projectParticipationCount?: number; + deliveredTaskCount?: number; + severityWeightedBugCount?: number; + delayRate?: number; +} + +export interface MemberRecommendationMetrics { + activeTaskCount: number; + activeBugCount: number; + deadlineConflictCount: number; + remainingHours: number; + recentOvertimeHours: number; + projectParticipationCount?: number; + bugRate?: number; + availableInDays: number; +} + +export interface MemberRecommendationItem { + name: string; + role: RecommendableRole; + score: number; + confidence: RecommendationConfidence; + reasons: string[]; + warnings: string[]; + metrics: MemberRecommendationMetrics; +} + +export interface MemberRecommendationGroup { + role: RecommendableRole; + requiredCount: number; + currentCount: number; + missingCount: number; + scopeHours: number; + items: MemberRecommendationItem[]; +} + +export interface MemberRecommendationInput { + candidates: VersionMemberCandidate[]; + currentMembers: VersionMember[]; + devTasks: DevTask[]; + testCases: TestCase[]; + bugs: Bug[]; + overtimeRecords?: OvertimeRecord[]; + versionDeadline?: string | null; + historicalStats?: HistoricalMemberStats[]; + scopeRequirements?: Requirement[]; + scopeDevTasks?: DevTask[]; + scopeTestCases?: TestCase[]; + taskCategories?: TaskCategory[]; + now?: Date; +} + +const ROLE_ORDER: RecommendableRole[] = ['frontend', 'backend', 'testing']; +const BUG_SEVERITY_WEIGHT: Record = { + critical: 4, + major: 3, + minor: 1.5, + trivial: 0.5, +}; +const AI_ASSISTED_ROLE_CAPACITY_HOURS: Record = { + frontend: 32, + backend: 32, + testing: 40, +}; +const REQUIREMENT_AI_SCOPE_HOURS: Record> = { + S: { frontend: 4, backend: 4, testing: 3 }, + M: { frontend: 8, backend: 8, testing: 5 }, + L: { frontend: 16, backend: 16, testing: 8 }, + XL: { frontend: 24, backend: 24, testing: 12 }, +}; + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} + +function roundTenth(value: number): number { + return Math.round(value * 10) / 10; +} + +function isTerminalDevTask(task: DevTask): boolean { + return task.status === 'submitted'; +} + +function isTerminalTestCase(testCase: TestCase): boolean { + return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked'; +} + +function isTerminalBug(bug: Bug): boolean { + return bug.status === 'closed' || bug.status === 'rejected'; +} + +function getDevTaskRemainingHours(task: DevTask): number { + const estimate = getEstimateHours(task); + if (estimate <= 0) return 2; + const progress = STATUS_PROGRESS[task.status] ?? 0; + return Math.max(0, estimate * (100 - progress) / 100); +} + +function getTestCaseRemainingHours(testCase: TestCase): number { + const estimate = getTestCaseEstimateHours(testCase); + if (estimate <= 0) return testCase.status === 'running' ? 0.75 : 1.5; + const progress = TEST_CASE_STATUS_PROGRESS[testCase.status] ?? 0; + return Math.max(0, estimate * (100 - progress) / 100); +} + +function getBugRemainingHours(bug: Bug): number { + if (typeof bug.estimateHours === 'number' && bug.estimateHours > 0) return bug.estimateHours; + if (typeof bug.aiEstimateHours === 'number' && bug.aiEstimateHours > 0) return bug.aiEstimateHours; + return Math.max(1, BUG_SEVERITY_WEIGHT[bug.severity] ?? 1); +} + +function isAfterDeadline(value: string | undefined, deadline: string | null | undefined): boolean { + if (!value || !deadline) return false; + const valueMs = new Date(value).getTime(); + const deadlineMs = new Date(deadline).getTime(); + return Number.isFinite(valueMs) && Number.isFinite(deadlineMs) && valueMs > deadlineMs; +} + +function inferRecommendableRole(candidate: VersionMemberCandidate): RecommendableRole | undefined { + const departmentName = candidate.departmentName?.toLowerCase() ?? ''; + if (!departmentName) return undefined; + if (departmentName.includes('测试') || departmentName.includes('质量') || departmentName.includes('qa')) return 'testing'; + if (departmentName.includes('后端') || departmentName.includes('backend') || departmentName.includes('server')) return 'backend'; + if (departmentName.includes('前端') || departmentName.includes('frontend') || departmentName.includes('web') || departmentName.includes('client')) return 'frontend'; + return undefined; +} + +function getConfidence(candidate: VersionMemberCandidate, stats: HistoricalMemberStats | undefined, hasCurrentActivity: boolean): RecommendationConfidence { + const hasHistory = Boolean( + stats && + ( + typeof stats.projectParticipationCount === 'number' || + typeof stats.deliveredTaskCount === 'number' || + typeof stats.severityWeightedBugCount === 'number' || + typeof stats.delayRate === 'number' + ), + ); + if (hasHistory) return 'high'; + if (candidate.departmentName || hasCurrentActivity) return 'medium'; + return 'low'; +} + +function getHistoricalBugRate(stats: HistoricalMemberStats | undefined): number | undefined { + if (!stats?.deliveredTaskCount || stats.deliveredTaskCount <= 0) return undefined; + if (typeof stats.severityWeightedBugCount !== 'number') return undefined; + return roundTenth(stats.severityWeightedBugCount / stats.deliveredTaskCount); +} + +function hasScopedDemandInput(input: MemberRecommendationInput): boolean { + return Boolean(input.scopeRequirements || input.scopeDevTasks || input.scopeTestCases); +} + +function countCurrentMembersByRole(currentMembers: VersionMember[]): Record { + const counts: Record = { frontend: 0, backend: 0, testing: 0 }; + for (const member of currentMembers) { + if (member.role === 'frontend' || member.role === 'backend' || member.role === 'testing') { + counts[member.role] += 1; + } + } + return counts; +} + +function getRequirementScopeHours(requirements: Requirement[]): Record { + const hours: Record = { frontend: 0, backend: 0, testing: 0 }; + for (const requirement of requirements) { + const scope = REQUIREMENT_AI_SCOPE_HOURS[requirement.effort] ?? REQUIREMENT_AI_SCOPE_HOURS.M; + hours.frontend += scope.frontend; + hours.backend += scope.backend; + hours.testing += scope.testing; + } + return hours; +} + +function inferDevTaskScopeRole(task: DevTask, categories: TaskCategory[]): RecommendableRole | undefined { + const category = categories.find((item) => item.id === task.categoryId); + const text = `${task.categoryId} ${category?.code ?? ''} ${category?.name ?? ''}`.toLowerCase(); + if (text.includes('frontend') || text.includes('前端') || text.includes('web') || text.includes('client')) return 'frontend'; + if ( + text.includes('backend') || + text.includes('server') || + text.includes('api') || + text.includes('database') || + text.includes('db') || + text.includes('后端') || + text.includes('接口') || + text.includes('数据库') + ) return 'backend'; + return undefined; +} + +function getScopedDevTaskHours(devTasks: DevTask[], categories: TaskCategory[]): Pick, 'frontend' | 'backend'> { + const hours = { frontend: 0, backend: 0 }; + for (const task of devTasks) { + const estimate = Math.max(getEstimateHours(task), 2); + const role = inferDevTaskScopeRole(task, categories); + if (role === 'frontend' || role === 'backend') { + hours[role] += estimate; + } else { + hours.frontend += estimate / 2; + hours.backend += estimate / 2; + } + } + return hours; +} + +function getScopedTestCaseHours(testCases: TestCase[]): number { + return testCases.reduce((sum, testCase) => sum + Math.max(getTestCaseEstimateHours(testCase), 1), 0); +} + +function getRoleRecommendationDemand(input: MemberRecommendationInput, scoped: boolean): Record> { + const currentCounts = countCurrentMembersByRole(input.currentMembers); + const hours: Record = { frontend: 0, backend: 0, testing: 0 }; + const scopeRequirements = input.scopeRequirements ?? []; + const scopeDevTasks = input.scopeDevTasks ?? []; + const scopeTestCases = input.scopeTestCases ?? []; + const taskCategories = input.taskCategories ?? []; + const requirementHours = getRequirementScopeHours(scopeRequirements); + + if (scopeDevTasks.length > 0) { + const devHours = getScopedDevTaskHours(scopeDevTasks, taskCategories); + hours.frontend = devHours.frontend; + hours.backend = devHours.backend; + } else if (scopeRequirements.length > 0) { + hours.frontend = requirementHours.frontend; + hours.backend = requirementHours.backend; + } + + if (scopeTestCases.length > 0) { + hours.testing = getScopedTestCaseHours(scopeTestCases); + } else if (scopeRequirements.length > 0) { + hours.testing = requirementHours.testing; + } + + return ROLE_ORDER.reduce((acc, role) => { + const requiredCount = scoped && hours[role] > 0 + ? Math.max(1, Math.ceil(hours[role] / AI_ASSISTED_ROLE_CAPACITY_HOURS[role])) + : 0; + const currentCount = currentCounts[role]; + acc[role] = { + role, + requiredCount, + currentCount, + missingCount: scoped ? Math.max(0, requiredCount - currentCount) : 0, + scopeHours: roundTenth(hours[role]), + }; + return acc; + }, {} as Record>); +} + +export function recommendVersionMembers(input: MemberRecommendationInput): MemberRecommendationGroup[] { + const currentMemberNames = new Set(input.currentMembers.map((member) => member.name)); + const statsByName = new Map((input.historicalStats ?? []).map((stats) => [stats.name, stats])); + const deadline = input.versionDeadline; + const scoped = hasScopedDemandInput(input); + const demandByRole = getRoleRecommendationDemand(input, scoped); + + const items = input.candidates + .filter((candidate) => !currentMemberNames.has(candidate.name)) + .map((candidate): MemberRecommendationItem | null => { + const role = inferRecommendableRole(candidate); + if (!role) return null; + + const activeDevTasks = input.devTasks.filter((task) => task.assigneeId === candidate.name && !isTerminalDevTask(task)); + const activeTestCases = input.testCases.filter((testCase) => testCase.assigneeId === candidate.name && !isTerminalTestCase(testCase)); + const activeBugs = input.bugs.filter((bug) => bug.assigneeId === candidate.name && !isTerminalBug(bug)); + const activeTaskCount = activeDevTasks.length + activeTestCases.length; + const activeBugCount = activeBugs.length; + const deadlineConflictCount = activeDevTasks.filter((task) => isAfterDeadline(task.expectedEndAt, deadline)).length; + const remainingHours = roundTenth( + activeDevTasks.reduce((sum, task) => sum + getDevTaskRemainingHours(task), 0) + + activeTestCases.reduce((sum, testCase) => sum + getTestCaseRemainingHours(testCase), 0) + + activeBugs.reduce((sum, bug) => sum + getBugRemainingHours(bug), 0), + ); + const activeBugWeight = activeBugs.reduce((sum, bug) => sum + (BUG_SEVERITY_WEIGHT[bug.severity] ?? 1), 0); + const recentOvertimeHours = roundTenth( + (input.overtimeRecords ?? []) + .filter((record) => record.person === candidate.name) + .reduce((sum, record) => sum + record.duration, 0), + ); + const stats = statsByName.get(candidate.name); + const projectParticipationCount = stats?.projectParticipationCount; + const bugRate = getHistoricalBugRate(stats); + const confidence = getConfidence(candidate, stats, activeTaskCount > 0 || activeBugCount > 0 || recentOvertimeHours > 0); + + const reasons: string[] = [`部门匹配${role === 'frontend' ? '前端' : role === 'backend' ? '后端' : '测试'}角色`]; + const warnings: string[] = []; + let score = 65; + + if (activeTaskCount === 0 && activeBugCount === 0) { + score += 18; + reasons.push('当前无进行中任务,空闲度高'); + } else { + score -= Math.min(35, activeTaskCount * 6 + activeBugCount * 5 + remainingHours * 1.2); + reasons.push(`当前 ${activeTaskCount + activeBugCount} 个进行中事项,剩余约 ${remainingHours}h`); + } + + if (deadlineConflictCount > 0) { + score -= 20; + warnings.push(`${deadlineConflictCount} 个任务预计截止晚于当前版本`); + } + + if (activeBugCount > 0) { + score -= Math.min(15, activeBugWeight * 3); + warnings.push(`当前有 ${activeBugCount} 个未关闭 Bug`); + } + + if (typeof projectParticipationCount === 'number' && projectParticipationCount > 0) { + score += Math.min(15, projectParticipationCount * 3); + reasons.push(`历史参与项目 ${projectParticipationCount} 次`); + } + + if (typeof bugRate === 'number') { + score -= Math.min(22, bugRate * 30); + if (bugRate <= 0.2) reasons.push('历史 Bug 率较低'); + if (bugRate >= 0.5) warnings.push('历史 Bug 率偏高'); + } + + if (typeof stats?.delayRate === 'number' && stats.delayRate > 0.2) { + score -= Math.min(12, stats.delayRate * 30); + warnings.push('历史延期率偏高'); + } + + if (recentOvertimeHours >= 8) { + score -= 8; + warnings.push('近期加班较多'); + } + + const metrics: MemberRecommendationMetrics = { + activeTaskCount, + activeBugCount, + deadlineConflictCount, + remainingHours, + recentOvertimeHours, + projectParticipationCount, + bugRate, + availableInDays: remainingHours > 0 ? Math.ceil(remainingHours / 8) : 0, + }; + + return { + name: candidate.name, + role, + score: clampScore(score), + confidence, + reasons, + warnings, + metrics, + }; + }) + .filter((item): item is MemberRecommendationItem => Boolean(item)); + + return ROLE_ORDER + .map((role) => { + const roleItems = items + .filter((item) => item.role === role) + .sort((a, b) => b.score - a.score || a.metrics.remainingHours - b.metrics.remainingHours || a.name.localeCompare(b.name)); + const demand = demandByRole[role]; + const missingCount = scoped ? demand.missingCount : roleItems.length; + return { + ...demand, + requiredCount: scoped ? demand.requiredCount : demand.currentCount + roleItems.length, + missingCount, + items: roleItems.slice(0, missingCount), + }; + }) + .filter((group) => group.items.length > 0); +} + +export function addRecommendedVersionMembers( + currentMembers: VersionMember[], + selectedNames: Iterable, + recommendations: MemberRecommendationItem[], +): VersionMember[] { + const existingNames = new Set(currentMembers.map((member) => member.name)); + const selectedNameSet = new Set(selectedNames); + const additions = recommendations + .filter((item) => selectedNameSet.has(item.name) && !existingNames.has(item.name)) + .map((item) => ({ name: item.name, role: item.role })); + + return [...currentMembers, ...additions]; +} + +export function getDefaultRecommendedMemberNames(groups: MemberRecommendationGroup[]): string[] { + return groups.flatMap((group) => group.items.map((item) => item.name)); +} From 4278e4d5658d47f2b58135e4ceab343512f58707 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Fri, 26 Jun 2026 17:17:35 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(workspace):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B4=BB=E5=8A=A8=E6=97=A5=E6=8A=A5=E5=BC=95?= =?UTF-8?q?=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/modules/data/data-keys.ts | 1 + .../src/modules/data/data.service.spec.ts | 4 + apps/web/app/workspace/page.tsx | 6 +- .../dev-task/DevTaskDetailDrawer.tsx | 72 +++++ .../components/workspace/DailyReportPanel.tsx | 102 +++++-- apps/web/lib/server-data.ts | 1 + apps/web/lib/work-activity-factory.test.ts | 130 +++++++++ apps/web/lib/work-activity-factory.ts | 270 ++++++++++++++++++ apps/web/lib/work-activity.test.ts | 33 +++ apps/web/lib/work-activity.ts | 69 +++++ apps/web/lib/workspace-daily-report.test.ts | 109 +++++++ apps/web/lib/workspace-daily-report.ts | 105 ++++++- apps/web/stores/useBugStore.ts | 10 + apps/web/stores/useDevTaskStore.ts | 16 ++ apps/web/stores/useTestCaseStore.ts | 12 + apps/web/stores/useVersionPlanStore.ts | 19 +- apps/web/stores/useWorkActivityStore.ts | 108 +++++++ docs/architecture.md | 12 + docs/decisions.md | 13 + docs/roadmap.md | 4 + docs/workflow.md | 18 ++ 21 files changed, 1090 insertions(+), 24 deletions(-) create mode 100644 apps/web/lib/work-activity-factory.test.ts create mode 100644 apps/web/lib/work-activity-factory.ts create mode 100644 apps/web/lib/work-activity.test.ts create mode 100644 apps/web/lib/work-activity.ts create mode 100644 apps/web/stores/useWorkActivityStore.ts diff --git a/apps/server/src/modules/data/data-keys.ts b/apps/server/src/modules/data/data-keys.ts index b9d8f8a..afdd4e4 100644 --- a/apps/server/src/modules/data/data-keys.ts +++ b/apps/server/src/modules/data/data-keys.ts @@ -8,6 +8,7 @@ export const APP_DATA_KEYS = [ 'members', 'task-categories', 'task-worklogs', + 'work-activities', 'overtime', ] as const; diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts index ca1d56a..ec7eb1f 100644 --- a/apps/server/src/modules/data/data.service.spec.ts +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -58,6 +58,10 @@ describe('DataService', () => { key: 'task-worklogs', value, }); + await expect(service.put('work-activities', value)).resolves.toEqual({ + key: 'work-activities', + value, + }); await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({ key: 'overtime', value, diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index d1c36b8..e900573 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -11,6 +11,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; +import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { flattenVersions } from '@/lib/derive'; import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine'; import type { WorkItem, WorkItemType } from '@/lib/workspace-engine'; @@ -55,6 +56,7 @@ export default function WorkspacePage() { const { testCases, fetchTestCases } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); + const { activities, fetchActivities } = useWorkActivityStore(); const user = useAuthStore((s) => s.user); const [activeTab, setActiveTab] = useState('all'); const [showCompleted, setShowCompleted] = useState(true); @@ -69,6 +71,7 @@ export default function WorkspacePage() { useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + useEffect(() => { fetchActivities(); }, [fetchActivities]); const userName = user?.name ?? ''; const allVersions = useMemo(() => flattenVersions(overview), [overview]); @@ -122,12 +125,13 @@ export default function WorkspacePage() { const dailyReport = useMemo(() => getWorkspaceDailyReport({ + activities, worklogs, workItems, userId: userName, date: today, }), - [worklogs, workItems, userName, today] + [activities, worklogs, workItems, userName, today] ); // 待办数量按 tab 分(受 version filter 影响) diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 90e4f5e..ab1eb1c 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; +import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; @@ -29,6 +30,7 @@ interface Props { export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) { const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); + const addProgressNote = useWorkActivityStore((s) => s.addProgressNote); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); @@ -36,6 +38,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const [transferTo, setTransferTo] = useState(''); const [showDelayInput, setShowDelayInput] = useState(false); const [delayReason, setDelayReason] = useState(''); + const [progressNote, setProgressNote] = useState(''); + const [progressBlocker, setProgressBlocker] = useState(''); + const [progressHelperId, setProgressHelperId] = useState(''); + const [progressDelayRisk, setProgressDelayRisk] = useState(''); const task = tasks.find((t) => t.id === taskId); if (!task) return null; @@ -86,6 +92,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel setBlockReason(''); }; + const handleProgressNote = () => { + const note = progressNote.trim(); + const blocker = progressBlocker.trim(); + const delayRisk = progressDelayRisk.trim(); + if (!note && !blocker && !delayRisk) return; + + addProgressNote({ + actorId: task.assigneeId, + sourceType: 'dev_task', + sourceId: task.id, + title: task.title, + note: note || '今日进展已更新', + blocker: blocker || undefined, + helperId: progressHelperId || undefined, + delayRisk: delayRisk || undefined, + }); + setProgressNote(''); + setProgressBlocker(''); + setProgressHelperId(''); + setProgressDelayRisk(''); + }; + return (
e.stopPropagation()}> @@ -199,6 +227,50 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
+ {task.status !== 'todo' && task.status !== 'submitted' && ( +
+
今日进展
+