feat(workspace): 增加个人今日日报列
This commit is contained in:
@@ -10,19 +10,22 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||
import { flattenVersions } from '@/lib/derive';
|
||||
import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine';
|
||||
import type { WorkItem, WorkItemType } from '@/lib/workspace-engine';
|
||||
import { DailyReportPanel } from '@/components/workspace/DailyReportPanel';
|
||||
import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer';
|
||||
import { DevTaskDetailDrawer } from '@/components/dev-task/DevTaskDetailDrawer';
|
||||
import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer';
|
||||
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
|
||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, getEstimateHours, getActualHours } from '@/lib/dev-task';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { formatDateTime, formatLocalDate } from '@/lib/format';
|
||||
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
|
||||
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
|
||||
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||
|
||||
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
|
||||
|
||||
@@ -51,6 +54,7 @@ export default function WorkspacePage() {
|
||||
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||
const { bugs, fetchBugs } = useBugStore();
|
||||
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||
const [showCompleted, setShowCompleted] = useState(true);
|
||||
@@ -64,6 +68,7 @@ export default function WorkspacePage() {
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||
|
||||
const userName = user?.name ?? '';
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
@@ -113,6 +118,18 @@ export default function WorkspacePage() {
|
||||
return items.sort((a, b) => (a.completed !== b.completed ? (a.completed ? 1 : -1) : 0));
|
||||
}, [workItems, activeTab, showCompleted, selectedVersionId]);
|
||||
|
||||
const today = useMemo(() => formatLocalDate(), []);
|
||||
|
||||
const dailyReport = useMemo(() =>
|
||||
getWorkspaceDailyReport({
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: userName,
|
||||
date: today,
|
||||
}),
|
||||
[worklogs, workItems, userName, today]
|
||||
);
|
||||
|
||||
// 待办数量按 tab 分(受 version filter 影响)
|
||||
const pendingByTab = useMemo(() => {
|
||||
const base = selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems;
|
||||
@@ -191,7 +208,7 @@ export default function WorkspacePage() {
|
||||
</div>
|
||||
|
||||
{/* 右侧:任务列表 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="min-w-0 flex-1 flex flex-col overflow-hidden">
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{TABS.find((t) => t.key === activeTab)?.label}</h2>
|
||||
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} 项</span>
|
||||
@@ -222,6 +239,8 @@ export default function WorkspacePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DailyReportPanel report={dailyReport} />
|
||||
|
||||
{/* Detail Drawers */}
|
||||
{drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && (
|
||||
<PlanDetailDrawer planId={drawerItem.id} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { getTaskWorklogs } from '@/lib/task-worklog';
|
||||
import { formatLocalDate } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
taskId: string;
|
||||
@@ -17,7 +18,7 @@ export function WorklogPanel({ taskId }: Props) {
|
||||
const taskLogs = getTaskWorklogs(worklogs, taskId);
|
||||
const totalHours = taskLogs.reduce((sum, w) => sum + w.hours, 0);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = formatLocalDate();
|
||||
const [date, setDate] = useState(today);
|
||||
const [hours, setHours] = useState<number>(1);
|
||||
const [workContent, setWorkContent] = useState('');
|
||||
|
||||
74
apps/web/components/workspace/DailyReportPanel.tsx
Normal file
74
apps/web/components/workspace/DailyReportPanel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarDays, ClipboardList, Clock3 } from 'lucide-react';
|
||||
import type { WorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||
import { formatWorkHours, formatWorkHoursShort } from '@/lib/work-hours';
|
||||
|
||||
interface Props {
|
||||
report: WorkspaceDailyReport;
|
||||
}
|
||||
|
||||
export function DailyReportPanel({ report }: Props) {
|
||||
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">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">今日日报</h2>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-[var(--ink-muted)]">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
<span>{report.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-[var(--line)] p-3">
|
||||
<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>
|
||||
</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>
|
||||
</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 ? (
|
||||
<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(' / ');
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
12
apps/web/lib/format.test.ts
Normal file
12
apps/web/lib/format.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatLocalDate } from './format';
|
||||
|
||||
test('formatLocalDate formats a local calendar date as YYYY-MM-DD', () => {
|
||||
assert.equal(formatLocalDate(new Date(2026, 5, 26, 23, 59)), '2026-06-26');
|
||||
});
|
||||
|
||||
test('formatLocalDate pads month and day', () => {
|
||||
assert.equal(formatLocalDate(new Date(2026, 0, 2, 8, 5)), '2026-01-02');
|
||||
});
|
||||
@@ -31,6 +31,16 @@ export function formatDate(iso?: string | null): string {
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 YYYY-MM-DD(本地日期,用于 date input / 今日筛选)
|
||||
*/
|
||||
export function formatLocalDate(date: Date = new Date()): string {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 MM-DD HH:mm(紧凑版,本地时区)
|
||||
*/
|
||||
|
||||
123
apps/web/lib/workspace-daily-report.test.ts
Normal file
123
apps/web/lib/workspace-daily-report.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
import { getWorkspaceDailyReport } from './workspace-daily-report';
|
||||
|
||||
const workItems: WorkItem[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
type: 'devTask',
|
||||
title: '实现登录接口',
|
||||
status: 'in_progress',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: '项目管理',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
raw: {} as any,
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
type: 'bug',
|
||||
title: '修复菜单错位',
|
||||
status: 'fixing',
|
||||
completed: false,
|
||||
productName: 'FTB',
|
||||
projectName: '项目管理',
|
||||
versionName: 'V1.0',
|
||||
versionId: 'version-1',
|
||||
raw: {} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const worklogs: TaskWorklog[] = [
|
||||
{
|
||||
id: 'wl-1',
|
||||
taskId: 'task-1',
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
hours: 1.5,
|
||||
workContent: '完成登录接口联调',
|
||||
createdAt: '2026-06-26T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'wl-2',
|
||||
taskId: 'task-2',
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
hours: 0.5,
|
||||
workContent: '定位菜单错位原因',
|
||||
createdAt: '2026-06-26T03:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'wl-other-user',
|
||||
taskId: 'task-1',
|
||||
userId: '李四',
|
||||
date: '2026-06-26',
|
||||
hours: 8,
|
||||
workContent: '其他人的日报',
|
||||
createdAt: '2026-06-26T04:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'wl-other-date',
|
||||
taskId: 'task-1',
|
||||
userId: '张三',
|
||||
date: '2026-06-25',
|
||||
hours: 8,
|
||||
workContent: '昨天的日报',
|
||||
createdAt: '2026-06-25T04:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
test('getWorkspaceDailyReport filters by current user and date', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.equal(report.totalHours, 2);
|
||||
assert.equal(report.totalCount, 2);
|
||||
assert.deepEqual(report.items.map((item) => item.id), ['wl-2', 'wl-1']);
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport resolves task title and workspace context', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
worklogs,
|
||||
workItems,
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.equal(report.items[0].taskTitle, '修复菜单错位');
|
||||
assert.equal(report.items[0].productName, 'FTB');
|
||||
assert.equal(report.items[0].projectName, '项目管理');
|
||||
assert.equal(report.items[0].versionName, 'V1.0');
|
||||
});
|
||||
|
||||
test('getWorkspaceDailyReport keeps logs whose task is no longer visible', () => {
|
||||
const report = getWorkspaceDailyReport({
|
||||
worklogs: [
|
||||
{
|
||||
id: 'wl-missing',
|
||||
taskId: 'deleted-task',
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
hours: 0.5,
|
||||
workContent: '处理历史任务',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
},
|
||||
],
|
||||
workItems: [],
|
||||
userId: '张三',
|
||||
date: '2026-06-26',
|
||||
});
|
||||
|
||||
assert.equal(report.totalHours, 0.5);
|
||||
assert.equal(report.totalCount, 1);
|
||||
assert.equal(report.items[0].taskTitle, '未知任务');
|
||||
assert.equal(report.items[0].workContent, '处理历史任务');
|
||||
});
|
||||
63
apps/web/lib/workspace-daily-report.ts
Normal file
63
apps/web/lib/workspace-daily-report.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
|
||||
export interface WorkspaceDailyReportItem {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
workContent: string;
|
||||
hours: number;
|
||||
createdAt: string;
|
||||
productName?: string;
|
||||
projectName?: string;
|
||||
versionName?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDailyReport {
|
||||
date: string;
|
||||
totalHours: number;
|
||||
totalCount: number;
|
||||
items: WorkspaceDailyReportItem[];
|
||||
}
|
||||
|
||||
interface GetWorkspaceDailyReportInput {
|
||||
worklogs: TaskWorklog[];
|
||||
workItems: WorkItem[];
|
||||
userId: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export function getWorkspaceDailyReport({
|
||||
worklogs,
|
||||
workItems,
|
||||
userId,
|
||||
date,
|
||||
}: GetWorkspaceDailyReportInput): WorkspaceDailyReport {
|
||||
const workItemMap = new Map(workItems.map((item) => [item.id, item]));
|
||||
|
||||
const items = worklogs
|
||||
.filter((log) => log.userId === userId && log.date === date)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.map((log) => {
|
||||
const item = workItemMap.get(log.taskId);
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
taskId: log.taskId,
|
||||
taskTitle: item?.title ?? '未知任务',
|
||||
workContent: log.workContent,
|
||||
hours: log.hours,
|
||||
createdAt: log.createdAt,
|
||||
productName: item?.productName,
|
||||
projectName: item?.projectName,
|
||||
versionName: item?.versionName,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
date,
|
||||
totalHours: items.reduce((sum, item) => sum + item.hours, 0),
|
||||
totalCount: items.length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user