feat(workspace): 增加工作活动日报引擎

This commit is contained in:
Script Generator
2026-06-26 17:17:35 +08:00
parent 64491789ae
commit 4278e4d565
21 changed files with 1090 additions and 24 deletions

View File

@@ -8,6 +8,7 @@ export const APP_DATA_KEYS = [
'members', 'members',
'task-categories', 'task-categories',
'task-worklogs', 'task-worklogs',
'work-activities',
'overtime', 'overtime',
] as const; ] as const;

View File

@@ -58,6 +58,10 @@ describe('DataService', () => {
key: 'task-worklogs', key: 'task-worklogs',
value, value,
}); });
await expect(service.put('work-activities', value)).resolves.toEqual({
key: 'work-activities',
value,
});
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({ await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({
key: 'overtime', key: 'overtime',
value, value,

View File

@@ -11,6 +11,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore'; import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { flattenVersions } from '@/lib/derive'; import { flattenVersions } from '@/lib/derive';
import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine'; import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
import type { WorkItem, WorkItemType } 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 { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore(); const { bugs, fetchBugs } = useBugStore();
const { worklogs, fetchWorklogs } = useTaskWorklogStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore();
const { activities, fetchActivities } = useWorkActivityStore();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [activeTab, setActiveTab] = useState<TabKey>('all'); const [activeTab, setActiveTab] = useState<TabKey>('all');
const [showCompleted, setShowCompleted] = useState(true); const [showCompleted, setShowCompleted] = useState(true);
@@ -69,6 +71,7 @@ export default function WorkspacePage() {
useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
useEffect(() => { fetchActivities(); }, [fetchActivities]);
const userName = user?.name ?? ''; const userName = user?.name ?? '';
const allVersions = useMemo(() => flattenVersions(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]);
@@ -122,12 +125,13 @@ export default function WorkspacePage() {
const dailyReport = useMemo(() => const dailyReport = useMemo(() =>
getWorkspaceDailyReport({ getWorkspaceDailyReport({
activities,
worklogs, worklogs,
workItems, workItems,
userId: userName, userId: userName,
date: today, date: today,
}), }),
[worklogs, workItems, userName, today] [activities, worklogs, workItems, userName, today]
); );
// 待办数量按 tab 分(受 version filter 影响) // 待办数量按 tab 分(受 version filter 影响)

View File

@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2,
import { StatusBadge } from './StatusBadge'; import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip'; import { CategoryChip } from './CategoryChip';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore'; import { useMemberStore } from '@/stores/useMemberStore';
@@ -29,6 +30,7 @@ interface Props {
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) { export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) {
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
const { categories } = useTaskCategoryStore(); const { categories } = useTaskCategoryStore();
const { requirements } = useRequirementStore(); const { requirements } = useRequirementStore();
const { members } = useMemberStore(); const { members } = useMemberStore();
@@ -36,6 +38,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
const [transferTo, setTransferTo] = useState(''); const [transferTo, setTransferTo] = useState('');
const [showDelayInput, setShowDelayInput] = useState(false); const [showDelayInput, setShowDelayInput] = useState(false);
const [delayReason, setDelayReason] = useState(''); 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); const task = tasks.find((t) => t.id === taskId);
if (!task) return null; if (!task) return null;
@@ -86,6 +92,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
setBlockReason(''); 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 ( return (
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}> <div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}> <div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
@@ -199,6 +227,50 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
</div> </div>
</div> </div>
{task.status !== 'todo' && task.status !== 'submitted' && (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide"></div>
<textarea
value={progressNote}
onChange={(e) => setProgressNote(e.target.value)}
placeholder="今日完成内容、剩余内容"
rows={3}
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
/>
<div className="grid grid-cols-2 gap-2">
<input
value={progressBlocker}
onChange={(e) => setProgressBlocker(e.target.value)}
placeholder="阻塞原因"
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
/>
<select
value={progressHelperId}
onChange={(e) => setProgressHelperId(e.target.value)}
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none"
>
<option value=""></option>
{members.filter((m) => m.name !== task.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
</div>
<input
value={progressDelayRisk}
onChange={(e) => setProgressDelayRisk(e.target.value)}
placeholder="延期风险"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
/>
<div className="flex justify-end">
<button
onClick={handleProgressNote}
disabled={!progressNote.trim() && !progressBlocker.trim() && !progressDelayRisk.trim()}
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
</button>
</div>
</div>
)}
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" /></div> <div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3 flex items-center gap-1.5"><CalendarRange className="h-3 w-3" /></div>
<div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]"> <div className="grid grid-cols-2 gap-y-2.5 gap-x-4 text-[12px]">

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { CalendarDays, ClipboardList, Clock3 } from 'lucide-react'; import { AlertTriangle, CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
import { WORK_ACTIVITY_CATEGORY_LABEL, type WorkActivityCategory } from '@/lib/work-activity';
import type { WorkspaceDailyReport } from '@/lib/workspace-daily-report'; import type { WorkspaceDailyReport } from '@/lib/workspace-daily-report';
import { formatDateTimeShort } from '@/lib/format';
import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours'; import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours';
interface Props { interface Props {
@@ -9,6 +11,11 @@ interface Props {
} }
export function DailyReportPanel({ report }: Props) { export function DailyReportPanel({ report }: Props) {
const groupOrder: WorkActivityCategory[] = ['delivery', 'progress', 'creation', 'risk', 'note'];
const hasActivities = groupOrder.some((key) => report.groups[key].length > 0);
const hasLegacyWorklogs = report.items.length > 0;
const hasNeedsProgress = report.needsProgressItems.length > 0;
return ( return (
<aside className="w-80 shrink-0 border-l border-[var(--line)] bg-[var(--bg-card)] flex flex-col"> <aside className="w-80 shrink-0 border-l border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
<div className="flex h-14 items-center justify-between border-b border-[var(--line)] px-4"> <div className="flex h-14 items-center justify-between border-b border-[var(--line)] px-4">
@@ -25,26 +32,59 @@ export function DailyReportPanel({ report }: Props) {
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2"> <div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]"> <div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]">
<Clock3 className="h-3 w-3" /> <Clock3 className="h-3 w-3" />
<span></span> <span></span>
</div> </div>
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{formatWorkHours(report.totalHours)}</p> <p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{formatWorkHours(report.totalHours)}</p>
</div> </div>
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2"> <div className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]"> <div className="flex items-center gap-1 text-[10px] text-[var(--ink-muted)]">
<ClipboardList className="h-3 w-3" /> <ClipboardList className="h-3 w-3" />
<span></span> <span></span>
</div> </div>
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{report.totalCount} </p> <p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{report.totalCount} </p>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
{report.items.length === 0 ? ( {!hasActivities && !hasLegacyWorklogs && !hasNeedsProgress ? (
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-4 text-center"> <div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg)] p-4 text-center">
<p className="text-[13px] font-medium text-[var(--ink)]"></p> <p className="text-[13px] font-medium text-[var(--ink)]"></p>
<p className="mt-1 text-[11px] leading-5 text-[var(--ink-muted)]"></p>
</div> </div>
) : ( ) : (
<div className="space-y-4">
{groupOrder.map((key) => {
const items = report.groups[key];
if (items.length === 0) return null;
return (
<section key={key}>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[11px] font-semibold text-[var(--ink-soft)]">{WORK_ACTIVITY_CATEGORY_LABEL[key]}</h3>
<span className="text-[10px] text-[var(--ink-muted)]">{items.length} </span>
</div>
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="rounded-lg border border-[var(--line)] bg-[var(--bg)] p-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 break-words text-[12px] font-medium leading-5 text-[var(--ink)]">{item.summary}</p>
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTimeShort(item.occurredAt)}</span>
</div>
{item.context && (
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={item.context}>{item.context}</p>
)}
</div>
))}
</div>
</section>
);
})}
{hasLegacyWorklogs && (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[11px] font-semibold text-[var(--ink-soft)]"></h3>
<span className="text-[10px] text-[var(--ink-muted)]">{report.items.length} </span>
</div>
<div className="space-y-2"> <div className="space-y-2">
{report.items.map((item) => { {report.items.map((item) => {
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / '); const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
@@ -67,6 +107,26 @@ export function DailyReportPanel({ report }: Props) {
); );
})} })}
</div> </div>
</section>
)}
{hasNeedsProgress && (
<section>
<div className="mb-2 flex items-center gap-1.5">
<AlertTriangle className="h-3 w-3 text-orange-500" />
<h3 className="text-[11px] font-semibold text-orange-700"></h3>
</div>
<div className="space-y-2">
{report.needsProgressItems.map((item) => (
<div key={item.id} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<p className="break-words text-[12px] font-medium leading-5 text-orange-800">{item.title}</p>
{item.context && <p className="mt-1 truncate text-[10px] text-orange-700/70" title={item.context}>{item.context}</p>}
</div>
))}
</div>
</section>
)}
</div>
)} )}
</div> </div>
</aside> </aside>

View File

@@ -10,6 +10,7 @@ export type ServerDataKey =
| 'members' | 'members'
| 'task-categories' | 'task-categories'
| 'task-worklogs' | 'task-worklogs'
| 'work-activities'
| 'overtime'; | 'overtime';
interface ServerDataResponse<T> { interface ServerDataResponse<T> {

View File

@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { VersionPlan } from './version-plan';
import {
makeBugStatusActivity,
makeDevTaskStatusActivity,
makeTestCaseStatusActivity,
makeVersionPlanCompletedActivity,
} from './work-activity-factory';
function devTask(patch: Partial<DevTask> = {}): DevTask {
return {
id: 'task-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: '实现登录接口',
categoryId: 'cat-1',
assigneeId: '张三',
priority: 'P2',
expectedStartAt: '2026-06-26T01:00',
expectedEndAt: '2026-06-26T09:00',
status: 'todo',
isBlocked: false,
createdBy: '张三',
createdAt: '2026-06-26T01:00:00.000Z',
updatedAt: '2026-06-26T01:00:00.000Z',
...patch,
};
}
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
return {
id: 'plan-1',
versionId: 'version-1',
type: 'product',
title: '提交产品方案',
owner: '张三',
startTime: '2026-06-26T01:00',
endTime: '2026-06-26T09:00',
status: 'in_progress',
createdAt: '2026-06-26',
addedBy: '张三',
...patch,
};
}
function bug(patch: Partial<Bug> = {}): Bug {
return {
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'version-1',
testCaseId: 'case-1',
title: '修复菜单错位',
description: '菜单在窄屏错位',
severity: 'major',
priority: 'P1',
reportedBy: '李四',
assigneeId: '张三',
status: 'fixing',
createdAt: '2026-06-26T01:00:00.000Z',
updatedAt: '2026-06-26T01:00:00.000Z',
...patch,
};
}
function testCase(patch: Partial<TestCase> = {}): TestCase {
return {
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'version-1',
title: '验证登录流程',
categoryId: 'cat-test',
priority: 'P2',
assigneeId: '张三',
status: 'pending',
createdBy: '张三',
createdAt: '2026-06-26T01:00:00.000Z',
updatedAt: '2026-06-26T01:00:00.000Z',
...patch,
};
}
test('makeDevTaskStatusActivity maps started and submitted actions', () => {
const started = makeDevTaskStatusActivity(devTask(), 'todo', 'in_progress', '张三');
const submitted = makeDevTaskStatusActivity(devTask({ status: 'testing' }), 'testing', 'submitted', '张三');
assert.equal(started?.action, 'dev_task_started');
assert.equal(started?.category, 'progress');
assert.equal(started?.summary, '开始开发:实现登录接口');
assert.equal(submitted?.action, 'dev_task_submitted');
assert.equal(submitted?.category, 'delivery');
assert.equal(submitted?.summary, '已提测开发任务:实现登录接口');
});
test('makeVersionPlanCompletedActivity marks product plan completion as delivery', () => {
const activity = makeVersionPlanCompletedActivity(plan(), '张三');
assert.equal(activity.action, 'version_plan_completed');
assert.equal(activity.category, 'delivery');
assert.equal(activity.sourceType, 'version_plan');
assert.equal(activity.summary, '完成产品方案:提交产品方案');
});
test('makeBugStatusActivity maps fixing and fixed actions', () => {
const fixing = makeBugStatusActivity(bug({ status: 'open' }), 'open', 'fixing', '张三');
const fixed = makeBugStatusActivity(bug(), 'fixing', 'fixed', '张三');
assert.equal(fixing?.action, 'bug_fixing');
assert.equal(fixing?.category, 'progress');
assert.equal(fixing?.summary, '开始修复 Bug修复菜单错位');
assert.equal(fixed?.action, 'bug_fixed');
assert.equal(fixed?.category, 'delivery');
assert.equal(fixed?.summary, '已修复 Bug修复菜单错位');
});
test('makeTestCaseStatusActivity maps running and passed actions', () => {
const running = makeTestCaseStatusActivity(testCase(), 'pending', 'running', '张三');
const passed = makeTestCaseStatusActivity(testCase({ status: 'running' }), 'running', 'passed', '张三');
assert.equal(running?.action, 'test_case_started');
assert.equal(running?.category, 'progress');
assert.equal(running?.summary, '开始测试:验证登录流程');
assert.equal(passed?.action, 'test_case_passed');
assert.equal(passed?.category, 'delivery');
assert.equal(passed?.summary, '测试通过:验证登录流程');
});

View File

@@ -0,0 +1,270 @@
import type { Bug, BugStatus } from './bug';
import type { DevTask, DevTaskStatus } from './dev-task';
import type { TestCase, TestCaseStatus } from './test-case';
import type { VersionPlan } from './version-plan';
import type { WorkActivityDraft } from './work-activity';
const PLAN_TYPE_LABEL: Record<VersionPlan['type'], string> = {
research: '调研',
product: '产品方案',
ui: 'UI设计',
};
export function makeVersionPlanCreatedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'version_plan',
sourceId: plan.id,
action: 'version_plan_created',
category: 'creation',
title: plan.title,
summary: `新建${PLAN_TYPE_LABEL[plan.type]}${plan.title}`,
};
}
export function makeVersionPlanStartedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'version_plan',
sourceId: plan.id,
action: 'version_plan_started',
category: 'progress',
title: plan.title,
summary: `开始${PLAN_TYPE_LABEL[plan.type]}${plan.title}`,
};
}
export function makeVersionPlanCompletedActivity(plan: VersionPlan, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'version_plan',
sourceId: plan.id,
action: 'version_plan_completed',
category: 'delivery',
title: plan.title,
summary: `完成${PLAN_TYPE_LABEL[plan.type]}${plan.title}`,
};
}
export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'dev_task',
sourceId: task.id,
action: 'dev_task_created',
category: 'creation',
title: task.title,
summary: `新建开发任务:${task.title}`,
};
}
export function makeDevTaskStatusActivity(
task: DevTask,
fromStatus: DevTaskStatus,
toStatus: DevTaskStatus,
actorId: string,
): WorkActivityDraft | undefined {
const base = {
actorId,
sourceType: 'dev_task' as const,
sourceId: task.id,
title: task.title,
metadata: { fromStatus, toStatus },
};
if (toStatus === 'in_progress') {
return {
...base,
action: 'dev_task_started',
category: 'progress',
summary: `开始开发:${task.title}`,
};
}
if (toStatus === 'testing') {
return {
...base,
action: 'dev_task_self_testing',
category: 'progress',
summary: `进入自测:${task.title}`,
};
}
if (toStatus === 'submitted') {
return {
...base,
action: 'dev_task_submitted',
category: 'delivery',
summary: `已提测开发任务:${task.title}`,
};
}
return undefined;
}
export function makeDevTaskBlockedActivity(
task: DevTask,
actorId: string,
reason?: string,
helperId?: string,
): WorkActivityDraft {
return {
actorId,
sourceType: 'dev_task',
sourceId: task.id,
action: 'dev_task_blocked',
category: 'risk',
title: task.title,
summary: `标记阻塞:${reason?.trim() || task.title}`,
metadata: {
blocker: reason?.trim() || undefined,
helperId: helperId?.trim() || undefined,
},
};
}
export function makeDevTaskUnblockedActivity(task: DevTask, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'dev_task',
sourceId: task.id,
action: 'dev_task_unblocked',
category: 'progress',
title: task.title,
summary: `解除阻塞:${task.title}`,
};
}
export function makeBugCreatedActivity(bug: Bug, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'bug',
sourceId: bug.id,
action: 'bug_created',
category: 'creation',
title: bug.title,
summary: `新建 Bug${bug.title}`,
};
}
export function makeTestCaseCreatedActivity(testCase: TestCase, actorId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'test_case',
sourceId: testCase.id,
action: 'test_case_created',
category: 'creation',
title: testCase.title,
summary: `新建测试用例:${testCase.title}`,
};
}
export function makeTestCaseStatusActivity(
testCase: TestCase,
fromStatus: TestCaseStatus,
toStatus: TestCaseStatus,
actorId: string,
): WorkActivityDraft | undefined {
const base = {
actorId,
sourceType: 'test_case' as const,
sourceId: testCase.id,
title: testCase.title,
metadata: { fromStatus, toStatus },
};
if (toStatus === 'running') {
return {
...base,
action: 'test_case_started',
category: 'progress',
summary: `开始测试:${testCase.title}`,
};
}
if (toStatus === 'passed') {
return {
...base,
action: 'test_case_passed',
category: 'delivery',
summary: `测试通过:${testCase.title}`,
};
}
if (toStatus === 'failed') {
return {
...base,
action: 'test_case_failed',
category: 'risk',
summary: `测试不通过:${testCase.title}`,
};
}
if (toStatus === 'blocked') {
return {
...base,
action: 'test_case_blocked',
category: 'risk',
summary: `测试阻塞:${testCase.title}`,
};
}
return undefined;
}
export function makeBugStatusActivity(
bug: Bug,
fromStatus: BugStatus,
toStatus: BugStatus,
actorId: string,
): WorkActivityDraft | undefined {
const base = {
actorId,
sourceType: 'bug' as const,
sourceId: bug.id,
title: bug.title,
metadata: { fromStatus, toStatus },
};
if (toStatus === 'fixing') {
return {
...base,
action: 'bug_fixing',
category: 'progress',
summary: `开始修复 Bug${bug.title}`,
};
}
if (toStatus === 'fixed') {
return {
...base,
action: 'bug_fixed',
category: 'delivery',
summary: `已修复 Bug${bug.title}`,
};
}
if (toStatus === 'closed') {
return {
...base,
action: 'bug_closed',
category: 'delivery',
summary: `已关闭 Bug${bug.title}`,
};
}
return undefined;
}
export function makeBugTransferredActivity(bug: Bug, actorId: string, toAssigneeId: string): WorkActivityDraft {
return {
actorId,
sourceType: 'bug',
sourceId: bug.id,
action: 'bug_transferred',
category: 'progress',
title: bug.title,
summary: `转交 Bug${bug.title}${toAssigneeId}`,
metadata: { toAssigneeId },
};
}

View File

@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { WorkActivity } from './work-activity';
import { mergeWorkActivities } from './work-activity';
function activity(id: string, summary: string): WorkActivity {
return {
id,
actorId: '张三',
date: '2026-06-26',
occurredAt: `2026-06-26T0${id.length}:00:00.000Z`,
sourceType: 'dev_task',
sourceId: id,
action: 'dev_task_started',
category: 'progress',
title: summary,
summary,
};
}
test('mergeWorkActivities preserves remote records and appends local records', () => {
const merged = mergeWorkActivities([activity('act-1', '远端记录')], [activity('act-2', '本地新增')]);
assert.deepEqual(merged.map((item) => item.id), ['act-1', 'act-2']);
});
test('mergeWorkActivities lets newer local records replace the same id', () => {
const merged = mergeWorkActivities([activity('act-1', '远端旧记录')], [activity('act-1', '本地新记录')]);
assert.equal(merged.length, 1);
assert.equal(merged[0].summary, '本地新记录');
});

View File

@@ -0,0 +1,69 @@
export type WorkActivitySourceType = 'version_plan' | 'dev_task' | 'test_case' | 'bug' | 'manual';
export type WorkActivityCategory = 'delivery' | 'progress' | 'creation' | 'risk' | 'note';
export type WorkActivityAction =
| 'version_plan_created'
| 'version_plan_started'
| 'version_plan_completed'
| 'dev_task_created'
| 'dev_task_started'
| 'dev_task_self_testing'
| 'dev_task_submitted'
| 'dev_task_blocked'
| 'dev_task_unblocked'
| 'dev_task_transferred'
| 'test_case_created'
| 'test_case_started'
| 'test_case_passed'
| 'test_case_failed'
| 'test_case_blocked'
| 'bug_created'
| 'bug_fixing'
| 'bug_fixed'
| 'bug_closed'
| 'bug_blocked'
| 'bug_transferred'
| 'progress_note_added';
export interface WorkActivity {
id: string;
actorId: string;
date: string;
occurredAt: string;
sourceType: WorkActivitySourceType;
sourceId: string;
action: WorkActivityAction;
category: WorkActivityCategory;
title: string;
summary: string;
metadata?: {
fromStatus?: string;
toStatus?: string;
note?: string;
blocker?: string;
helperId?: string;
delayRisk?: string;
[key: string]: unknown;
};
}
export type WorkActivityDraft = Omit<WorkActivity, 'id' | 'date' | 'occurredAt'> & {
date?: string;
occurredAt?: string;
};
export const WORK_ACTIVITY_CATEGORY_LABEL: Record<WorkActivityCategory, string> = {
delivery: '今日交付',
progress: '今日推进',
creation: '今日新增',
risk: '风险/阻塞',
note: '进展说明',
};
export function mergeWorkActivities(remote: WorkActivity[] = [], local: WorkActivity[] = []): WorkActivity[] {
const byId = new Map<string, WorkActivity>();
for (const item of remote) byId.set(item.id, item);
for (const item of local) byId.set(item.id, item);
return Array.from(byId.values()).sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
}

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { TaskWorklog } from './task-worklog'; import type { TaskWorklog } from './task-worklog';
import type { WorkActivity } from './work-activity';
import type { WorkItem } from './workspace-engine'; import type { WorkItem } from './workspace-engine';
import { getWorkspaceDailyReport } from './workspace-daily-report'; import { getWorkspaceDailyReport } from './workspace-daily-report';
@@ -16,6 +17,7 @@ const workItems: WorkItem[] = [
projectName: '项目管理', projectName: '项目管理',
versionName: 'V1.0', versionName: 'V1.0',
versionId: 'version-1', versionId: 'version-1',
extra: { actualStartAt: '2026-06-26T01:00:00.000Z' },
raw: {} as any, raw: {} as any,
}, },
{ {
@@ -30,6 +32,19 @@ const workItems: WorkItem[] = [
versionId: 'version-1', versionId: 'version-1',
raw: {} as any, raw: {} as any,
}, },
{
id: 'task-3',
type: 'devTask',
title: '跨天开发任务',
status: 'in_progress',
completed: false,
productName: 'FTB',
projectName: '项目管理',
versionName: 'V1.0',
versionId: 'version-1',
extra: { actualStartAt: '2026-06-25T02:00:00.000Z' },
raw: {} as any,
},
]; ];
const worklogs: TaskWorklog[] = [ const worklogs: TaskWorklog[] = [
@@ -71,6 +86,71 @@ const worklogs: TaskWorklog[] = [
}, },
]; ];
const activities: WorkActivity[] = [
{
id: 'act-delivery',
actorId: '张三',
date: '2026-06-26',
occurredAt: '2026-06-26T06:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-1',
action: 'dev_task_submitted',
category: 'delivery',
title: '实现登录接口',
summary: '已提测开发任务:实现登录接口',
},
{
id: 'act-risk',
actorId: '张三',
date: '2026-06-26',
occurredAt: '2026-06-26T05:00:00.000Z',
sourceType: 'bug',
sourceId: 'task-2',
action: 'bug_blocked',
category: 'risk',
title: '修复菜单错位',
summary: '标记阻塞:等待设计确认',
metadata: { blocker: '等待设计确认', helperId: '李四' },
},
{
id: 'act-note',
actorId: '张三',
date: '2026-06-26',
occurredAt: '2026-06-26T04:30:00.000Z',
sourceType: 'manual',
sourceId: 'task-1',
action: 'progress_note_added',
category: 'note',
title: '实现登录接口',
summary: '补充进展:完成接口联调',
metadata: { note: '完成接口联调' },
},
{
id: 'act-other-user',
actorId: '李四',
date: '2026-06-26',
occurredAt: '2026-06-26T07:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-1',
action: 'dev_task_started',
category: 'progress',
title: '其他人的任务',
summary: '其他人的日报活动',
},
{
id: 'act-other-date',
actorId: '张三',
date: '2026-06-25',
occurredAt: '2026-06-25T07:00:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-1',
action: 'dev_task_started',
category: 'progress',
title: '昨天的任务',
summary: '昨天的日报活动',
},
];
test('getWorkspaceDailyReport filters by current user and date', () => { test('getWorkspaceDailyReport filters by current user and date', () => {
const report = getWorkspaceDailyReport({ const report = getWorkspaceDailyReport({
worklogs, worklogs,
@@ -121,3 +201,32 @@ test('getWorkspaceDailyReport keeps logs whose task is no longer visible', () =>
assert.equal(report.items[0].taskTitle, '未知任务'); assert.equal(report.items[0].taskTitle, '未知任务');
assert.equal(report.items[0].workContent, '处理历史任务'); assert.equal(report.items[0].workContent, '处理历史任务');
}); });
test('getWorkspaceDailyReport groups current user activities by category', () => {
const report = getWorkspaceDailyReport({
activities,
worklogs,
workItems,
userId: '张三',
date: '2026-06-26',
});
assert.deepEqual(report.groups.delivery.map((item) => item.id), ['act-delivery']);
assert.deepEqual(report.groups.risk.map((item) => item.id), ['act-risk']);
assert.deepEqual(report.groups.note.map((item) => item.id), ['act-note']);
assert.equal(report.totalCount, 5);
assert.equal(report.groups.delivery[0].context, 'FTB / 项目管理 / V1.0');
});
test('getWorkspaceDailyReport flags multi-day in-progress items without today progress', () => {
const report = getWorkspaceDailyReport({
activities,
worklogs,
workItems,
userId: '张三',
date: '2026-06-26',
});
assert.deepEqual(report.needsProgressItems.map((item) => item.id), ['task-3']);
assert.equal(report.needsProgressItems[0].title, '跨天开发任务');
});

View File

@@ -1,4 +1,5 @@
import type { TaskWorklog } from './task-worklog'; import type { TaskWorklog } from './task-worklog';
import type { WorkActivity, WorkActivityCategory } from './work-activity';
import type { WorkItem } from './workspace-engine'; import type { WorkItem } from './workspace-engine';
export interface WorkspaceDailyReportItem { export interface WorkspaceDailyReportItem {
@@ -13,14 +14,40 @@ export interface WorkspaceDailyReportItem {
versionName?: string; versionName?: string;
} }
export interface WorkspaceDailyReportActivityItem {
id: string;
sourceId: string;
sourceType: WorkActivity['sourceType'];
action: WorkActivity['action'];
category: WorkActivityCategory;
title: string;
summary: string;
occurredAt: string;
context?: string;
metadata?: WorkActivity['metadata'];
}
export interface WorkspaceDailyReportNeedsProgressItem {
id: string;
title: string;
status: string;
type: WorkItem['type'];
context?: string;
}
export type WorkspaceDailyReportGroups = Record<WorkActivityCategory, WorkspaceDailyReportActivityItem[]>;
export interface WorkspaceDailyReport { export interface WorkspaceDailyReport {
date: string; date: string;
totalHours: number; totalHours: number;
totalCount: number; totalCount: number;
items: WorkspaceDailyReportItem[]; items: WorkspaceDailyReportItem[];
groups: WorkspaceDailyReportGroups;
needsProgressItems: WorkspaceDailyReportNeedsProgressItem[];
} }
interface GetWorkspaceDailyReportInput { interface GetWorkspaceDailyReportInput {
activities?: WorkActivity[];
worklogs: TaskWorklog[]; worklogs: TaskWorklog[];
workItems: WorkItem[]; workItems: WorkItem[];
userId: string; userId: string;
@@ -28,6 +55,7 @@ interface GetWorkspaceDailyReportInput {
} }
export function getWorkspaceDailyReport({ export function getWorkspaceDailyReport({
activities = [],
worklogs, worklogs,
workItems, workItems,
userId, userId,
@@ -54,10 +82,85 @@ export function getWorkspaceDailyReport({
}; };
}); });
const dailyActivities = activities
.filter((activity) => activity.actorId === userId && activity.date === date)
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const groups = makeEmptyGroups();
for (const activity of dailyActivities) {
const item = workItemMap.get(activity.sourceId);
groups[activity.category].push({
id: activity.id,
sourceId: activity.sourceId,
sourceType: activity.sourceType,
action: activity.action,
category: activity.category,
title: activity.title,
summary: activity.summary,
occurredAt: activity.occurredAt,
context: getContext(item),
metadata: activity.metadata,
});
}
const touchedSourceIds = new Set([
...dailyActivities.map((activity) => activity.sourceId),
...items.map((item) => item.taskId),
]);
const needsProgressItems = workItems
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
.map((item) => ({
id: item.id,
title: item.title,
status: item.status,
type: item.type,
context: getContext(item),
}));
return { return {
date, date,
totalHours: items.reduce((sum, item) => sum + item.hours, 0), totalHours: items.reduce((sum, item) => sum + item.hours, 0),
totalCount: items.length, totalCount: items.length + dailyActivities.length,
items, items,
groups,
needsProgressItems,
}; };
} }
function makeEmptyGroups(): WorkspaceDailyReportGroups {
return {
delivery: [],
progress: [],
creation: [],
risk: [],
note: [],
};
}
function getContext(item?: WorkItem): string | undefined {
if (!item) return undefined;
return [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
}
function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set<string>): boolean {
if (item.completed) return false;
if (touchedSourceIds.has(item.id)) return false;
if (!isInProgressStatus(item.status)) return false;
const startedAt = getStartedAt(item);
if (!startedAt) return false;
return startedAt.slice(0, 10) < date;
}
function isInProgressStatus(status: string): boolean {
return ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(status);
}
function getStartedAt(item: WorkItem): string | undefined {
const extra = item.extra ?? {};
if (typeof extra.actualStartAt === 'string') return extra.actualStartAt;
if (typeof extra.startedAt === 'string') return extra.startedAt;
if (typeof extra.startTime === 'string') return extra.startTime;
return undefined;
}

View File

@@ -4,6 +4,12 @@ import type { Bug, BugStatus, BugLog } from '@/lib/bug';
import { generateBugNo } from '@/lib/bug'; import { generateBugNo } from '@/lib/bug';
import { applyBugTransition } from '@/lib/bug-workflow'; import { applyBugTransition } from '@/lib/bug-workflow';
import { loadServerData, saveServerData } from '@/lib/server-data'; import { loadServerData, saveServerData } from '@/lib/server-data';
import {
makeBugCreatedActivity,
makeBugStatusActivity,
makeBugTransferredActivity,
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
function saveStored(items: Bug[]) { function saveStored(items: Bug[]) {
saveServerData('bugs', items).catch(() => {}); saveServerData('bugs', items).catch(() => {});
@@ -57,6 +63,7 @@ export const useBugStore = create<BugState>((set, get) => ({
const updated = [...list, bug]; const updated = [...list, bug];
set({ bugs: updated }); set({ bugs: updated });
saveStored(updated); saveStored(updated);
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
return bug; return bug;
}, },
@@ -83,6 +90,8 @@ export const useBugStore = create<BugState>((set, get) => ({
}); });
if (!result.ok || !result.patch) return { ok: false, message: result.message }; if (!result.ok || !result.patch) return { ok: false, message: result.message };
get().updateBug(id, result.patch); get().updateBug(id, result.patch);
const activity = makeBugStatusActivity(bug, bug.status, to, operator);
if (activity) useWorkActivityStore.getState().addActivity(activity);
return { ok: true }; return { ok: true };
}, },
@@ -92,6 +101,7 @@ export const useBugStore = create<BugState>((set, get) => ({
if (bug.assigneeId === newAssigneeId) return { ok: false, message: '已是当前负责人' }; if (bug.assigneeId === newAssigneeId) return { ok: false, message: '已是当前负责人' };
const log = makeLog('transfer', operator, bug.assigneeId, newAssigneeId, remark); const log = makeLog('transfer', operator, bug.assigneeId, newAssigneeId, remark);
get().updateBug(id, { assigneeId: newAssigneeId, logs: [...(bug.logs || []), log] }); get().updateBug(id, { assigneeId: newAssigneeId, logs: [...(bug.logs || []), log] });
useWorkActivityStore.getState().addActivity(makeBugTransferredActivity(bug, operator, newAssigneeId));
return { ok: true }; return { ok: true };
}, },

View File

@@ -5,6 +5,13 @@ import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow'; import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
import { loadServerData, saveServerData } from '@/lib/server-data'; import { loadServerData, saveServerData } from '@/lib/server-data';
import {
makeDevTaskBlockedActivity,
makeDevTaskCreatedActivity,
makeDevTaskStatusActivity,
makeDevTaskUnblockedActivity,
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
function saveStored(items: DevTask[]) { function saveStored(items: DevTask[]) {
saveServerData('dev-tasks', items).catch(() => {}); saveServerData('dev-tasks', items).catch(() => {});
@@ -60,6 +67,7 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
const updated = [...list, task]; const updated = [...list, task];
set({ tasks: updated }); set({ tasks: updated });
saveStored(updated); saveStored(updated);
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
return task; return task;
}, },
@@ -86,15 +94,23 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
}); });
if (!result.ok || !result.patch) return { ok: false, message: result.message }; if (!result.ok || !result.patch) return { ok: false, message: result.message };
get().updateTask(id, result.patch); get().updateTask(id, result.patch);
const activity = makeDevTaskStatusActivity(task, task.status, to, task.assigneeId);
if (activity) useWorkActivityStore.getState().addActivity(activity);
return { ok: true }; return { ok: true };
}, },
setBlocked: (id, blocked, reason, blockedById) => { setBlocked: (id, blocked, reason, blockedById) => {
const task = get().tasks.find((t) => t.id === id);
if (!task) return;
get().updateTask(id, { get().updateTask(id, {
isBlocked: blocked, isBlocked: blocked,
blockReason: blocked ? reason : undefined, blockReason: blocked ? reason : undefined,
blockedById: blocked ? blockedById : undefined, blockedById: blocked ? blockedById : undefined,
}); });
const activity = blocked
? makeDevTaskBlockedActivity(task, task.assigneeId, reason, blockedById)
: makeDevTaskUnblockedActivity(task, task.assigneeId);
useWorkActivityStore.getState().addActivity(activity);
}, },
getByRequirement: (requirementId) => { getByRequirement: (requirementId) => {

View File

@@ -5,6 +5,11 @@ import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
import { loadServerData, saveServerData } from '@/lib/server-data'; import { loadServerData, saveServerData } from '@/lib/server-data';
import {
makeTestCaseCreatedActivity,
makeTestCaseStatusActivity,
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
function saveStored(items: TestCase[]) { function saveStored(items: TestCase[]) {
saveServerData('test-cases', items).catch(() => {}); saveServerData('test-cases', items).catch(() => {});
@@ -56,6 +61,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
const updated = [...list, tc]; const updated = [...list, tc];
set({ testCases: updated }); set({ testCases: updated });
saveStored(updated); saveStored(updated);
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
return tc; return tc;
}, },
@@ -78,6 +84,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
} }
set({ testCases: list }); set({ testCases: list });
saveStored(list); saveStored(list);
created.forEach((tc) => {
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
});
return created; return created;
}, },
@@ -105,6 +114,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
}); });
if (!result.ok || !result.patch) return { ok: false, message: result.message }; if (!result.ok || !result.patch) return { ok: false, message: result.message };
get().updateTestCase(id, result.patch); get().updateTestCase(id, result.patch);
const actorId = tc.assigneeId || tc.executedBy || tc.createdBy;
const activity = makeTestCaseStatusActivity(tc, tc.status, to, actorId);
if (activity) useWorkActivityStore.getState().addActivity(activity);
return { ok: true }; return { ok: true };
}, },

View File

@@ -4,6 +4,12 @@ import type { VersionPlan, PlanType } from '@/lib/version-plan';
import { loadServerData, saveServerData } from '@/lib/server-data'; import { loadServerData, saveServerData } from '@/lib/server-data';
import { getPlanCompletionState } from '@/lib/version-plan-workflow'; import { getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow'; import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import {
makeVersionPlanCompletedActivity,
makeVersionPlanCreatedActivity,
makeVersionPlanStartedActivity,
} from '@/lib/work-activity-factory';
import { useWorkActivityStore } from './useWorkActivityStore';
const MOCK_PLANS: VersionPlan[] = []; const MOCK_PLANS: VersionPlan[] = [];
@@ -40,10 +46,12 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const plans = [...get().plans, plan]; const plans = [...get().plans, plan];
set({ plans }); set({ plans });
saveStored(plans); saveStored(plans);
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
}, },
updatePlan: (id, data) => { updatePlan: (id, data) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
const activities: ReturnType<typeof makeVersionPlanStartedActivity>[] = [];
const plans = get().plans.map((p) => { const plans = get().plans.map((p) => {
if (p.id !== id) return p; if (p.id !== id) return p;
const patch = { ...data }; const patch = { ...data };
@@ -53,10 +61,18 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
if (patch.status === 'completed' && !p.completedAt) { if (patch.status === 'completed' && !p.completedAt) {
(patch as any).completedAt = now; (patch as any).completedAt = now;
} }
return { ...p, ...patch }; const next = { ...p, ...patch };
if (p.status !== 'in_progress' && next.status === 'in_progress') {
activities.push(makeVersionPlanStartedActivity(next, next.owner));
}
if (p.status !== 'completed' && next.status === 'completed') {
activities.push(makeVersionPlanCompletedActivity(next, next.owner));
}
return next;
}); });
set({ plans }); set({ plans });
saveStored(plans); saveStored(plans);
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
}, },
completePlan: (id, result) => { completePlan: (id, result) => {
@@ -70,6 +86,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
return p; return p;
} }
response = { ok: true }; response = { ok: true };
useWorkActivityStore.getState().addActivity(makeVersionPlanCompletedActivity(next, next.owner));
return next; return next;
}); });
set({ plans }); set({ plans });

View File

@@ -0,0 +1,108 @@
'use client';
import { create } from 'zustand';
import { formatLocalDate } from '@/lib/format';
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft, type WorkActivitySourceType } from '@/lib/work-activity';
import { loadServerData, saveServerData } from '@/lib/server-data';
interface ProgressNoteInput {
actorId: string;
sourceType: WorkActivitySourceType;
sourceId: string;
title: string;
note: string;
blocker?: string;
helperId?: string;
delayRisk?: string;
}
interface WorkActivityState {
activities: WorkActivity[];
fetchActivities: () => Promise<void>;
addActivity: (data: WorkActivityDraft) => WorkActivity;
addProgressNote: (data: ProgressNoteInput) => WorkActivity;
deleteActivity: (id: string) => void;
}
async function saveStored(
items: WorkActivity[],
setActivities?: (items: WorkActivity[]) => void,
options: { mergeRemote?: boolean } = {},
) {
try {
if (options.mergeRemote === false) {
await saveServerData('work-activities', items);
return;
}
const remote = await loadServerData<WorkActivity[]>('work-activities');
const merged = mergeWorkActivities(Array.isArray(remote) ? remote : [], items);
setActivities?.(merged);
await saveServerData('work-activities', merged);
} catch {
saveServerData('work-activities', items).catch(() => {});
}
}
async function loadStored(): Promise<WorkActivity[] | null> {
try {
return await loadServerData<WorkActivity[]>('work-activities');
} catch {}
return null;
}
function createActivityId(): string {
return `act-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
activities: [],
fetchActivities: async () => {
const cached = await loadStored();
if (cached) set({ activities: cached });
},
addActivity: (data) => {
const now = new Date();
const item: WorkActivity = {
...data,
id: createActivityId(),
date: data.date ?? formatLocalDate(now),
occurredAt: data.occurredAt ?? now.toISOString(),
};
const updated = [...get().activities, item];
set({ activities: updated });
saveStored(updated, (activities) => set({ activities }));
return item;
},
addProgressNote: (data) => {
const details = [
data.note.trim(),
data.blocker?.trim() ? `阻塞:${data.blocker.trim()}` : '',
data.helperId?.trim() ? `需协助:${data.helperId.trim()}` : '',
data.delayRisk?.trim() ? `延期风险:${data.delayRisk.trim()}` : '',
].filter(Boolean);
return get().addActivity({
actorId: data.actorId,
sourceType: data.sourceType,
sourceId: data.sourceId,
action: 'progress_note_added',
category: data.blocker?.trim() || data.delayRisk?.trim() ? 'risk' : 'note',
title: data.title,
summary: `补充进展:${details.join('')}`,
metadata: {
note: data.note.trim(),
blocker: data.blocker?.trim() || undefined,
helperId: data.helperId?.trim() || undefined,
delayRisk: data.delayRisk?.trim() || undefined,
},
});
},
deleteActivity: (id) => {
const updated = get().activities.filter((activity) => activity.id !== id);
set({ activities: updated });
saveStored(updated, (activities) => set({ activities }), { mergeRemote: false });
},
}));

View File

@@ -165,3 +165,15 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBACOwner/Admin/Member/
- `task-category.ts`DevTask/TestCase 共用任务类型字典,`id` 用于存储,`code` 用于 AI 语义映射。 - `task-category.ts`DevTask/TestCase 共用任务类型字典,`id` 用于存储,`code` 用于 AI 语义映射。
页面组件只消费规则层输出,不直接拼完成条件或候选筛选条件。 页面组件只消费规则层输出,不直接拼完成条件或候选筛选条件。
## Work Activity Daily Report Layer (2026-06-26)
The personal daily report is derived from two inputs:
- `work-activities`: append-only activity records created by successful business actions.
- `task-worklogs`: legacy/manual worklog records that still contribute hours and written work content.
`work-activity-factory.ts` owns the mapping from domain actions to reportable activity semantics. Zustand stores call this factory after a successful operation, then append the result through `useWorkActivityStore`.
`workspace-daily-report.ts` remains a pure aggregation engine. It groups today's current-user activity into delivery, progress, creation, risk, and note sections, and also detects in-progress work that started before today but has no activity or progress note today.
This is intentionally not a generic rules engine or event bus. The rule surface is explicit, typed, and local to the workspace/daily-report use case.

View File

@@ -330,3 +330,16 @@
- 测试用例按所属需求分组时展示“已提测/待提测”标签;只有该需求下所有开发任务都 `submitted` 才展示“已提测”,否则展示“待提测”。 - 测试用例按所属需求分组时展示“已提测/待提测”标签;只有该需求下所有开发任务都 `submitted` 才展示“已提测”,否则展示“待提测”。
**理由**:第一轮承载完整测试范围,后续轮次应复跑同一范围而不是临时拼装;执行记录按轮次隔离,整体投入按版本累计,能同时回答“这一轮测得怎么样”和“这个版本测试总共花了多少”。 **理由**:第一轮承载完整测试范围,后续轮次应复跑同一范围而不是临时拼装;执行记录按轮次隔离,整体投入按版本累计,能同时回答“这一轮测得怎么样”和“这个版本测试总共花了多少”。
## 28. Daily report uses work activity log, not a generic rules engine
**Problem**: A daily report based only on manual `task-worklogs` misses important actions such as submitting a product plan, starting a development task, submitting code to test, fixing bugs, or marking blockers.
**Decision**: Add a lightweight `work-activities` document key and a typed `work-activity-factory.ts`. Business stores append activity records after successful operations. The workspace daily report derives a personal report from activities, legacy worklogs, and current work items.
**Why**:
- Automatic activity records provide evidence that work happened today.
- Manual progress notes explain multi-day work when no status changed today.
- A generic rules engine is too heavy for the current AppData stage and would hide business rules behind configuration.
- A pure aggregation function keeps report behavior testable and predictable.
**Rule**: Key status changes count as daily evidence. Multi-day in-progress work without today's activity or progress note is flagged as needing a progress update.

View File

@@ -136,3 +136,7 @@ NestJS + Prisma + PostgreSQL 已开始接入。第一阶段先用 `app_data` JSO
| V2 后端接入 | 进行中V2.1 AppData 已实现) | | V2 后端接入 | 进行中V2.1 AppData 已实现) |
| V3 AI 集成 | 等 V2 数据沉淀 | | V3 AI 集成 | 等 V2 数据沉淀 |
| 公开发布 | TBD | | 公开发布 | TBD |
**2026-06-26**
- Workspace daily report upgraded from manual worklog summary to mixed activity aggregation.
- Added `work-activities` AppData key and a typed activity factory for VersionPlan, DevTask, TestCase, and Bug actions.
- `/workspace` daily report now groups delivery/progress/creation/risk/progress-note records and flags in-progress work that needs today's progress update.

View File

@@ -203,3 +203,21 @@ AI 估时约束:
- `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。 - `version-plan-workflow.ts` 是调研/产品方案/UI 设计完成条件的唯一入口。
- `requirement-selector.ts` 是版本内关联需求候选的唯一入口。 - `requirement-selector.ts` 是版本内关联需求候选的唯一入口。
- `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点,`id` 只作为存储主键。 - `TaskCategory.code` 是 AI 和系统任务类型的稳定映射锚点,`id` 只作为存储主键。
## Work Activity Daily Report Flow (2026-06-26)
The daily report flow uses mixed evidence:
1. Automatic evidence is written when a user performs a successful domain action:
- VersionPlan created, started, or completed.
- DevTask created, started, moved to self-test, submitted to test, blocked, or unblocked.
- TestCase created, started, passed, failed, or blocked.
- Bug created, moved to fixing, fixed, closed, or transferred.
2. Manual progress notes are used for multi-day work that does not change status today.
3. `/workspace` shows only the current logged-in user's report.
4. Project-owner and management views will reuse the same `work-activities` data later, but are not part of the personal workspace panel.
Implementation convention:
- Activity wording and category mapping belong in `apps/web/lib/work-activity-factory.ts`.
- Daily report grouping belongs in `apps/web/lib/workspace-daily-report.ts`.
- Page components should consume report output, not rebuild report rules.