feat(workspace): 增加工作活动日报引擎
This commit is contained in:
@@ -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<TabKey>('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 影响)
|
||||
|
||||
@@ -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 (
|
||||
<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()}>
|
||||
@@ -199,6 +227,50 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
||||
</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="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]">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'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 { formatDateTimeShort } from '@/lib/format';
|
||||
import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +11,11 @@ interface 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 (
|
||||
<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">
|
||||
@@ -25,47 +32,100 @@ export function DailyReportPanel({ report }: Props) {
|
||||
<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)]">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
<span>今日合计</span>
|
||||
<span>工时合计</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{formatWorkHours(report.totalHours)}</p>
|
||||
</div>
|
||||
<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)]">
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
<span>已登记</span>
|
||||
<span>日报记录</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[16px] font-semibold text-[var(--ink)]">{report.totalCount} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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 className="space-y-2">
|
||||
{report.items.map((item) => {
|
||||
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
<div className="space-y-4">
|
||||
{groupOrder.map((key) => {
|
||||
const items = report.groups[key];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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.workContent}</p>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)]">
|
||||
{formatWorkHoursShort(item.hours)}
|
||||
</span>
|
||||
<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>
|
||||
<p className="mt-2 truncate text-[11px] text-[var(--ink-soft)]" title={item.taskTitle}>{item.taskTitle}</p>
|
||||
{contextLabel && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</p>
|
||||
)}
|
||||
</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">
|
||||
{report.items.map((item) => {
|
||||
const contextLabel = [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
|
||||
|
||||
return (
|
||||
<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.workContent}</p>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--accent)]">
|
||||
{formatWorkHoursShort(item.hours)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-[11px] text-[var(--ink-soft)]" title={item.taskTitle}>{item.taskTitle}</p>
|
||||
{contextLabel && (
|
||||
<p className="mt-1 truncate text-[10px] text-[var(--ink-muted)]" title={contextLabel}>
|
||||
{contextLabel}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ServerDataKey =
|
||||
| 'members'
|
||||
| 'task-categories'
|
||||
| 'task-worklogs'
|
||||
| 'work-activities'
|
||||
| 'overtime';
|
||||
|
||||
interface ServerDataResponse<T> {
|
||||
|
||||
130
apps/web/lib/work-activity-factory.test.ts
Normal file
130
apps/web/lib/work-activity-factory.test.ts
Normal 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, '测试通过:验证登录流程');
|
||||
});
|
||||
270
apps/web/lib/work-activity-factory.ts
Normal file
270
apps/web/lib/work-activity-factory.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
33
apps/web/lib/work-activity.test.ts
Normal file
33
apps/web/lib/work-activity.test.ts
Normal 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, '本地新记录');
|
||||
});
|
||||
69
apps/web/lib/work-activity.ts
Normal file
69
apps/web/lib/work-activity.ts
Normal 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));
|
||||
}
|
||||
@@ -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, '跨天开发任务');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { Bug, BugStatus, BugLog } from '@/lib/bug';
|
||||
import { generateBugNo } from '@/lib/bug';
|
||||
import { applyBugTransition } from '@/lib/bug-workflow';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import {
|
||||
makeBugCreatedActivity,
|
||||
makeBugStatusActivity,
|
||||
makeBugTransferredActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: Bug[]) {
|
||||
saveServerData('bugs', items).catch(() => {});
|
||||
@@ -57,6 +63,7 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
const updated = [...list, bug];
|
||||
set({ bugs: updated });
|
||||
saveStored(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
||||
return bug;
|
||||
},
|
||||
|
||||
@@ -83,6 +90,8 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
get().updateBug(id, result.patch);
|
||||
const activity = makeBugStatusActivity(bug, bug.status, to, operator);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
@@ -92,6 +101,7 @@ export const useBugStore = create<BugState>((set, get) => ({
|
||||
if (bug.assigneeId === newAssigneeId) return { ok: false, message: '已是当前负责人' };
|
||||
const log = makeLog('transfer', operator, bug.assigneeId, newAssigneeId, remark);
|
||||
get().updateBug(id, { assigneeId: newAssigneeId, logs: [...(bug.logs || []), log] });
|
||||
useWorkActivityStore.getState().addActivity(makeBugTransferredActivity(bug, operator, newAssigneeId));
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||
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[]) {
|
||||
saveServerData('dev-tasks', items).catch(() => {});
|
||||
@@ -60,6 +67,7 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
const updated = [...list, task];
|
||||
set({ tasks: updated });
|
||||
saveStored(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
||||
return task;
|
||||
},
|
||||
|
||||
@@ -86,15 +94,23 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
get().updateTask(id, result.patch);
|
||||
const activity = makeDevTaskStatusActivity(task, task.status, to, task.assigneeId);
|
||||
if (activity) useWorkActivityStore.getState().addActivity(activity);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
setBlocked: (id, blocked, reason, blockedById) => {
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
get().updateTask(id, {
|
||||
isBlocked: blocked,
|
||||
blockReason: blocked ? reason : undefined,
|
||||
blockedById: blocked ? blockedById : undefined,
|
||||
});
|
||||
const activity = blocked
|
||||
? makeDevTaskBlockedActivity(task, task.assigneeId, reason, blockedById)
|
||||
: makeDevTaskUnblockedActivity(task, task.assigneeId);
|
||||
useWorkActivityStore.getState().addActivity(activity);
|
||||
},
|
||||
|
||||
getByRequirement: (requirementId) => {
|
||||
|
||||
@@ -5,6 +5,11 @@ import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
||||
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import {
|
||||
makeTestCaseCreatedActivity,
|
||||
makeTestCaseStatusActivity,
|
||||
} from '@/lib/work-activity-factory';
|
||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||
|
||||
function saveStored(items: TestCase[]) {
|
||||
saveServerData('test-cases', items).catch(() => {});
|
||||
@@ -56,6 +61,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
const updated = [...list, tc];
|
||||
set({ testCases: updated });
|
||||
saveStored(updated);
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
return tc;
|
||||
},
|
||||
|
||||
@@ -78,6 +84,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
}
|
||||
set({ testCases: list });
|
||||
saveStored(list);
|
||||
created.forEach((tc) => {
|
||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||
});
|
||||
return created;
|
||||
},
|
||||
|
||||
@@ -105,6 +114,9 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
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 };
|
||||
},
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
||||
import { getPlanCompletionState } 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[] = [];
|
||||
|
||||
@@ -40,10 +46,12 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
const plans = [...get().plans, plan];
|
||||
set({ plans });
|
||||
saveStored(plans);
|
||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
||||
},
|
||||
|
||||
updatePlan: (id, data) => {
|
||||
const now = new Date().toISOString();
|
||||
const activities: ReturnType<typeof makeVersionPlanStartedActivity>[] = [];
|
||||
const plans = get().plans.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const patch = { ...data };
|
||||
@@ -53,10 +61,18 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
if (patch.status === 'completed' && !p.completedAt) {
|
||||
(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 });
|
||||
saveStored(plans);
|
||||
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
||||
},
|
||||
|
||||
completePlan: (id, result) => {
|
||||
@@ -70,6 +86,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||
return p;
|
||||
}
|
||||
response = { ok: true };
|
||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCompletedActivity(next, next.owner));
|
||||
return next;
|
||||
});
|
||||
set({ plans });
|
||||
|
||||
108
apps/web/stores/useWorkActivityStore.ts
Normal file
108
apps/web/stores/useWorkActivityStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user