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

@@ -10,6 +10,7 @@ export type ServerDataKey =
| 'members'
| 'task-categories'
| 'task-worklogs'
| 'work-activities'
| 'overtime';
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 type { TaskWorklog } from './task-worklog';
import type { WorkActivity } from './work-activity';
import type { WorkItem } from './workspace-engine';
import { getWorkspaceDailyReport } from './workspace-daily-report';
@@ -16,6 +17,7 @@ const workItems: WorkItem[] = [
projectName: '项目管理',
versionName: 'V1.0',
versionId: 'version-1',
extra: { actualStartAt: '2026-06-26T01:00:00.000Z' },
raw: {} as any,
},
{
@@ -30,6 +32,19 @@ const workItems: WorkItem[] = [
versionId: 'version-1',
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[] = [
@@ -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', () => {
const report = getWorkspaceDailyReport({
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].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 { WorkActivity, WorkActivityCategory } from './work-activity';
import type { WorkItem } from './workspace-engine';
export interface WorkspaceDailyReportItem {
@@ -13,14 +14,40 @@ export interface WorkspaceDailyReportItem {
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 {
date: string;
totalHours: number;
totalCount: number;
items: WorkspaceDailyReportItem[];
groups: WorkspaceDailyReportGroups;
needsProgressItems: WorkspaceDailyReportNeedsProgressItem[];
}
interface GetWorkspaceDailyReportInput {
activities?: WorkActivity[];
worklogs: TaskWorklog[];
workItems: WorkItem[];
userId: string;
@@ -28,6 +55,7 @@ interface GetWorkspaceDailyReportInput {
}
export function getWorkspaceDailyReport({
activities = [],
worklogs,
workItems,
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 {
date,
totalHours: items.reduce((sum, item) => sum + item.hours, 0),
totalCount: items.length,
totalCount: items.length + dailyActivities.length,
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;
}