feat(workspace): 增加工作活动日报引擎
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 影响)
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
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 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, '跨天开发任务');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -165,3 +165,15 @@ V2 接入后端后改为基于 `ProjectMember` 表的 RBAC(Owner/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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user