diff --git a/client/src/api/portal.ts b/client/src/api/portal.ts index 10445f9a..dc2b58bc 100644 --- a/client/src/api/portal.ts +++ b/client/src/api/portal.ts @@ -1,5 +1,36 @@ import { apiRequestData } from "./http"; +export type PortalRecentActivity = { + id: string; + type: + | "reimbursement.submit" + | "reimbursement.approve" + | "reimbursement.reject" + | "leave.submit" + | "leave.approve" + | "leave.reject" + | "attendance.clock_in" + | "attendance.clock_out" + | "attendance.supplement" + | "asset.assign" + | "asset.return"; + actorName: string; + summary: string; + createdAt: string; + href: string; +}; + +export type PortalTeamStatusItem = { + employeeId: string; + name: string; + avatarUrl: string | null; + presenceStatus: string | null; + attendanceClockIn: string | null; + attendanceClockOut: string | null; + onLeave: boolean; + leaveType: string | null; +}; + export type PortalHomePayload = { workspace: { id: string | null; @@ -55,8 +86,14 @@ export type PortalHomePayload = { hints: string[]; } | null; + recentActivities: PortalRecentActivity[]; + teamStatusBoard: PortalTeamStatusItem[]; }; export async function fetchPortalHome() { return apiRequestData("/api/portal/home"); } + +export async function fetchPortalTeamStatus() { + return apiRequestData("/api/portal/team-status"); +} diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index df51866e..52941e6e 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -8,12 +8,14 @@ import { fetchMyTodayAttendance, } from "../api/attendance"; import { fetchNotifications } from "../api/notifications"; +import { fetchPortalTeamStatus } from "../api/portal"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence"; import { fetchWorkflowTasks } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission } from "../auth/permissions"; +import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { PageHeader } from "../components/common/PageHeader"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; @@ -53,6 +55,21 @@ function formatDateTime(value: string | null) { } } +function formatShortTime(value: string | null) { + if (!value) { + return "--:--"; + } + try { + return new Date(value).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + } catch { + return value; + } +} + function presenceStatusLabel(item: PresenceItem | null) { if (!item) { return "未更新"; @@ -123,6 +140,50 @@ function resolveNotificationTargetPath( return "/notifications"; } +function workflowEntityLabel(entityType: string | null | undefined) { + if (entityType === "leave") return "请假"; + if (entityType === "reimbursement") return "报销"; + if (entityType === "purchase_request") return "采购"; + if (entityType === "travel_request") return "差旅"; + return entityType || "审批"; +} + +function resolveTeamStatusBadge(item: { + onLeave: boolean; + leaveType: string | null; + presenceStatus: string | null; + attendanceClockIn: string | null; +}) { + if (item.onLeave) { + return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning" }; + } + if (item.presenceStatus === "office") { + return { label: "在岗", className: "badge-success" }; + } + if (item.presenceStatus === "out") { + return { + label: "外出", + className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10", + }; + } + if (item.presenceStatus === "travel") { + return { + label: "差旅", + className: "inline-flex items-center rounded-full bg-sky-50 px-2 py-0.5 text-[11px] font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10", + }; + } + if (item.presenceStatus === "remote") { + return { label: "居家", className: "badge-neutral" }; + } + if (!item.attendanceClockIn) { + return { label: "未打卡", className: "badge-neutral" }; + } + return { + label: "已打卡", + className: "inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10", + }; +} + function QuickLinkCard({ label, description, @@ -183,11 +244,13 @@ export function MobilePortalPage() { const pendingCountQuery = useQuery({ enabled: canAccessApprovals, - queryKey: ["mobile", "workflow", "pending-count"], - queryFn: async () => { - const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 }); - return response.total; - }, + queryKey: ["mobile", "workflow", "pending-items"], + queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }), + }); + + const teamStatusQuery = useQuery({ + queryKey: ["mobile", "portal", "team-status"], + queryFn: () => fetchPortalTeamStatus(), }); const notificationsQuery = useQuery({ @@ -249,6 +312,8 @@ export function MobilePortalPage() { const presenceItem = presenceQuery.data?.item ?? null; const attendanceToday = attendanceTodayQuery.data ?? null; const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); + const teamStatusItems = teamStatusQuery.data ?? []; + const pendingTasks = pendingCountQuery.data?.items ?? []; const notifications = notificationsQuery.data?.items ?? []; return ( @@ -333,6 +398,48 @@ export function MobilePortalPage() { ) : null} +
+
+
+
团队状态
+
查看全员今日在岗、请假和打卡状态。
+
+ {teamStatusItems.length} 人 +
+ + {teamStatusQuery.isLoading ?

加载团队状态中...

: null} + {teamStatusQuery.isError ? ( +

+ {resolveErrorMessage(teamStatusQuery.error)} +

+ ) : null} + {!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? ( +

暂无团队状态。

+ ) : null} + +
+ {teamStatusItems.map((item) => { + const badge = resolveTeamStatusBadge(item); + return ( +
+
+ +
+
{item.name}
+
+ {item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"} +
+
+
+
+ {badge.label} +
+
+ ); + })} +
+
+
@@ -369,20 +476,47 @@ export function MobilePortalPage() {
-
待审批
+
待办聚合
- {canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"} + {canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
- {canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"} + {canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
{canAccessApprovals ? ( - + <> + + + {pendingCountQuery.isLoading ?

加载待办中...

: null} + {pendingCountQuery.isError ? ( +

+ {resolveErrorMessage(pendingCountQuery.error)} +

+ ) : null} + {!pendingCountQuery.isLoading && !pendingTasks.length ? ( +

当前没有审批待办。

+ ) : null} + +
+ {pendingTasks.map((task) => ( + +
{task.stepName}
+
+ 业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"} +
+ + ))} +
+ ) : (
你当前没有审批权限,审批页入口已禁用。 diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index b5645648..d26bd648 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -1,10 +1,23 @@ import { Link } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { fetchPortalHome, type PortalHomePayload } from "../api/portal"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + clockInAttendance, + clockOutAttendance, + fetchMyTodayAttendance, + type AttendanceTodayResponse, +} from "../api/attendance"; import { mapApiErrorMessage } from "../api/errorMessages"; +import { + fetchPortalHome, + type PortalHomePayload, + type PortalRecentActivity, + type PortalTeamStatusItem, +} from "../api/portal"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, isManagerRole } from "../auth/permissions"; +import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { EmptyStateCard } from "../components/common/EmptyStateCard"; import { MetricCard } from "../components/patterns/MetricCard"; import { PageScaffold } from "../components/patterns/PageScaffold"; @@ -23,6 +36,32 @@ type SummaryItem = { tone?: "normal" | "warning"; }; +type TodoAggregateItem = { + title: string; + sourceLabel: string; + severity: "normal" | "warning" | "high"; + href: string; + icon: string; +}; + +type QuickActionConfig = { + key: string; + icon: string; + label: string; + description: string; + to?: string; + onClick?: () => void; + disabled?: boolean; +}; + +const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"]; +const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"]; +const TODO_SEVERITY_RANK = { + high: 3, + warning: 2, + normal: 1, +} as const; + function isSummaryItem(item: SummaryItem | null): item is SummaryItem { return Boolean(item); } @@ -47,6 +86,251 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) { return payload.status === "submitted" ? "已提交" : "待填写"; } +function resolveErrorMessage(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "请求失败,请稍后重试。"; +} + +function isAttendanceFeatureDisabled(error: unknown) { + return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled"; +} + +function formatRelativeTime(value: string | null) { + if (!value) { + return "-"; + } + + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) { + return "-"; + } + + const diffMs = Date.now() - timestamp; + if (diffMs < 60 * 1000) { + return "刚刚"; + } + + const diffMinutes = Math.floor(diffMs / (60 * 1000)); + if (diffMinutes < 60) { + return `${diffMinutes} 分钟前`; + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours} 小时前`; + } + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays} 天前`; +} + +function formatShortTime(value: string | null) { + if (!value) { + return "--:--"; + } + try { + return new Date(value).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + } catch { + return value; + } +} + +function formatWorkMinutes(value: number | null | undefined) { + if (value == null) { + return "-"; + } + const hours = Math.floor(value / 60); + const minutes = value % 60; + return `${hours}h ${String(minutes).padStart(2, "0")}m`; +} + +function buildTodoAggregateItems(payload: PortalHomePayload) { + const items: TodoAggregateItem[] = []; + if (payload.pendingTasks.count > 0) { + items.push({ + title: `审批待办 ${payload.pendingTasks.count} 条`, + sourceLabel: "审批", + severity: "warning", + href: payload.pendingTasks.href, + icon: "fact_check", + }); + } + if ((payload.todoSummary?.openCount ?? 0) > 0) { + items.push({ + title: `TODO 待办 ${payload.todoSummary?.openCount ?? 0} 条`, + sourceLabel: "TODO", + severity: (payload.todoSummary?.highPriorityCount ?? 0) > 0 ? "high" : "normal", + href: payload.todoSummary?.href ?? "/todos", + icon: "check_circle", + }); + } + for (const card of payload.cards) { + if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) { + continue; + } + items.push({ + title: `${card.title}:${card.value}`, + sourceLabel: "工作台", + severity: card.severity, + href: card.href, + icon: card.severity === "high" ? "priority_high" : "warning", + }); + } + + const deduped = new Map(); + for (const item of items) { + deduped.set(`${item.title}|${item.href}|${item.sourceLabel}`, item); + } + + return Array.from(deduped.values()) + .sort((left, right) => { + const severityCompare = TODO_SEVERITY_RANK[right.severity] - TODO_SEVERITY_RANK[left.severity]; + if (severityCompare !== 0) { + return severityCompare; + } + return left.title.localeCompare(right.title, "zh-CN"); + }) + .slice(0, 10); +} + +function resolveTeamStatusBadge(item: PortalTeamStatusItem) { + if (item.onLeave) { + return { + label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", + className: "badge-warning", + }; + } + if (item.presenceStatus === "office") { + return { label: "在岗", className: "badge-success" }; + } + if (item.presenceStatus === "out") { + return { + label: "外出", + className: "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10", + }; + } + if (item.presenceStatus === "travel") { + return { + label: "差旅", + className: "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10", + }; + } + if (item.presenceStatus === "remote") { + return { label: "居家", className: "badge-neutral" }; + } + if (!item.attendanceClockIn) { + return { label: "未打卡", className: "badge-neutral" }; + } + return { + label: "已打卡", + className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10", + }; +} + +function formatTeamStatusAttendance(item: PortalTeamStatusItem) { + if (item.onLeave) { + return item.leaveType ? `${item.leaveType} 已审批` : "今日请假"; + } + if (item.attendanceClockIn && item.attendanceClockOut) { + return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`; + } + if (item.attendanceClockIn) { + return `${formatShortTime(item.attendanceClockIn)} 已打卡`; + } + if (item.presenceStatus === "travel") { + return "今日差旅"; + } + if (item.presenceStatus === "out") { + return "当前外出"; + } + if (item.presenceStatus === "remote") { + return "今日居家办公"; + } + return "暂无打卡记录"; +} + +function resolveClockAction( + attendanceToday: AttendanceTodayResponse | null, + state: { + isLoading: boolean; + isError: boolean; + featureDisabled: boolean; + pending: boolean; + }, +) { + if (state.pending) { + return { + label: "处理中...", + description: "正在提交考勤动作", + disabled: true, + action: null, + } as const; + } + if (state.isLoading) { + return { + label: "打卡读取中", + description: "正在读取今日考勤状态", + disabled: true, + action: null, + } as const; + } + if (state.isError && !state.featureDisabled) { + return { + label: "打卡不可用", + description: "当前无法读取今日考勤状态", + disabled: true, + action: null, + } as const; + } + if (!attendanceToday) { + return { + label: "今日考勤", + description: "暂无可用考勤数据", + disabled: true, + action: null, + } as const; + } + if (!attendanceToday.needClockIn) { + return { + label: "今日无需打卡", + description: "系统已自动排除缺卡统计", + disabled: true, + action: null, + } as const; + } + if (!attendanceToday.item?.clockInAt) { + return { + label: "上班打卡", + description: "记录今日到岗时间", + disabled: false, + action: "clock-in", + } as const; + } + if (!attendanceToday.item.clockOutAt) { + return { + label: "下班打卡", + description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`, + disabled: false, + action: "clock-out", + } as const; + } + return { + label: "今日已完成", + description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`, + disabled: true, + action: null, + } as const; +} + function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) { if (!items.length) { return ; @@ -197,12 +481,196 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl ); } +function QuickActionTile({ + icon, + label, + description, + to, + onClick, + disabled = false, +}: QuickActionConfig) { + const baseClassName = disabled + ? "rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-left text-zinc-400" + : "rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition-colors hover:bg-zinc-50"; + + const content = ( + <> +
+
+ {icon} +
+
+
{label}
+
{description}
+
+
+ + ); + + if (to && !disabled) { + return {content}; + } + if (onClick) { + return ( + + ); + } + return
{content}
; +} + +function QuickActionsPanel({ + items, +}: { + items: QuickActionConfig[]; +}) { + return ( + +
+ {items.length ? items.map((item) => ( + + )) : } +
+
+ ); +} + +function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) { + return ( + +
+ {items.length ? items.map((item) => ( + +
+
+ {item.icon} +
+
+
+
{item.title}
+ {item.sourceLabel} + {item.severity === "high" ? 高优先 : null} + {item.severity === "warning" ? 关注 : null} +
+
进入处理
+
+
+ + )) : } +
+
+ ); +} + +function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { + return ( + +
+ {items.length ? items.map((item) => { + const badge = resolveTeamStatusBadge(item); + return ( +
+
+ +
+
{item.name}
+
{formatTeamStatusAttendance(item)}
+
+ {badge.label} +
+
+ ); + }) : } +
+
+ ); +} + +function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) { + return ( + +
+ {items.length ? items.map((item) => ( + +
+ +
+
{item.summary}
+
{formatRelativeTime(item.createdAt)}
+
+
+ + )) : } +
+
+ ); +} + export function PortalHomePage() { + const queryClient = useQueryClient(); const { permissions } = useAuth(); const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); + const attendanceTodayQuery = useQuery({ + queryKey: ["portal-home", "attendance", "today"], + queryFn: () => fetchMyTodayAttendance(), + retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2, + }); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); const isManagerView = isManagerRole(permissions); + const attendanceClockInMutation = useMutation({ + mutationFn: () => clockInAttendance(), + onSuccess: async (response) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["portal-home"] }), + queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), + ]); + toast.success( + response.item.isLate + ? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟` + : "上班打卡成功", + ); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + + const attendanceClockOutMutation = useMutation({ + mutationFn: () => clockOutAttendance(), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["portal-home"] }), + queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }), + queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), + ]); + toast.success("下班打卡成功"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + if (portalQuery.isLoading) { return
加载 Portal 中...
; } @@ -219,6 +687,15 @@ export function PortalHomePage() { const primaryCards = pickCards(payload.cards, portalSchema.heroCards); const primaryCardKeySet = primaryCards.map((card) => card.key); const secondaryCards = compactCards(payload.cards, primaryCardKeySet); + const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); + const attendanceToday = attendanceTodayQuery.data ?? null; + const clockAction = resolveClockAction(attendanceToday, { + isLoading: attendanceTodayQuery.isLoading, + isError: attendanceTodayQuery.isError, + featureDisabled: attendanceFeatureDisabled, + pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending, + }); + const todoAggregateItems = buildTodoAggregateItems(payload); const employeeSummaryItems = ([ { @@ -272,6 +749,49 @@ export function PortalHomePage() { : null, ] as Array).filter(isSummaryItem); + const quickActions: QuickActionConfig[] = []; + if (hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) { + quickActions.push({ + key: "leave", + icon: "event_available", + label: "提交请假", + description: "直接进入请假申请页", + to: "/leave-requests?action=create", + }); + } + if (hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) { + quickActions.push({ + key: "expense", + icon: "request_quote", + label: "提交报销", + description: "进入报销工作台创建单据", + to: "/reimbursements", + }); + } + if (!attendanceFeatureDisabled) { + quickActions.push({ + key: "attendance", + icon: clockAction.action === "clock-out" ? "logout" : "schedule", + label: clockAction.label, + description: clockAction.description, + disabled: clockAction.disabled, + onClick: clockAction.action === "clock-in" + ? () => attendanceClockInMutation.mutate() + : clockAction.action === "clock-out" + ? () => attendanceClockOutMutation.mutate() + : undefined, + }); + } + if (payload.weeklyReport || payload.weeklySummary) { + quickActions.push({ + key: "weekly", + icon: "article", + label: "提交周报", + description: "进入周报页继续填写或查看团队缺口", + to: "/weekly-reports", + }); + } + return ( +
+ + +
+ +
+ + +
+ {isManagerView ? ( <>
diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index 8e8a4c64..fbf0b4b6 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -11,7 +11,6 @@ function envelope(data: unknown) { }; } - function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) { return page.route("**/api/me", async (route) => { await route.fulfill({ @@ -101,6 +100,21 @@ function mockNavigation(page: import("@playwright/test").Page, mode: "employee" }); } +function mockAttendanceToday(page: import("@playwright/test").Page) { + return page.route("**/api/attendance/me/today", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + workDate: "2026-03-22", + needClockIn: true, + exemptionReason: null, + item: null, + })), + }); + }); +} + function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") { return page.route("**/api/portal/home", async (route) => { const data = mode === "manager" @@ -133,6 +147,14 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" projectSummary: { myCount: 5, overdueMilestonesCount: 2, highPriorityTaskCount: 2, openRiskCount: 1, followUpDueTodayCount: 2, closurePendingProjectCount: 1, href: "/projects" }, peopleLifecycle: { onboardingCount: 1, offboardingCount: 0, assetReturnPendingCount: 1, href: "/people" }, adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 1, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] }, + recentActivities: [ + { id: "activity-1", type: "reimbursement.submit", actorName: "张三", summary: "张三 提交了报销单", createdAt: "2026-03-22T09:00:00.000Z", href: "/reimbursements/reimbursement-1" }, + { id: "activity-2", type: "attendance.clock_in", actorName: "李四", summary: "李四 完成了上班打卡", createdAt: "2026-03-22T08:50:00.000Z", href: "/attendance" }, + ], + teamStatusBoard: [ + { employeeId: "employee-1", name: "张三", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null }, + { employeeId: "employee-2", name: "李四", avatarUrl: null, presenceStatus: "leave", attendanceClockIn: null, attendanceClockOut: null, onLeave: true, leaveType: "年假" }, + ], } : { workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" }, @@ -162,6 +184,13 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" projectSummary: { myCount: 2, overdueMilestonesCount: 1, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 1, closurePendingProjectCount: 0, href: "/projects" }, peopleLifecycle: null, adoptionSummary: null, + recentActivities: [ + { id: "activity-1", type: "leave.submit", actorName: "Employee", summary: "Employee 提交了请假单", createdAt: "2026-03-22T09:00:00.000Z", href: "/leave-requests" }, + ], + teamStatusBoard: [ + { employeeId: "employee-1", name: "Employee", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-03-22T00:55:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null }, + { employeeId: "employee-2", name: "同事", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null }, + ], }; await route.fulfill({ @@ -175,6 +204,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { await mockNavigation(page, "employee"); await mockPortalHome(page, "employee"); + await mockAttendanceToday(page); await mockMe(page, ["employee"], "Employee"); await login(page, EMPLOYEE); @@ -195,11 +225,16 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { await expect(page.getByText("我的申请与资产")).toBeVisible(); await expect(page.getByRole("heading", { name: "公告" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "近期动态流" })).toBeVisible(); }); test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => { await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); + await mockAttendanceToday(page); await mockMe(page, ["admin"], "Admin"); await login(page, ADMIN); @@ -226,6 +261,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page test("Portal 域管理角色也进入经理视图", async ({ page }) => { await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); + await mockAttendanceToday(page); await mockMe(page, ["administration"], "Administration"); await login(page, EMPLOYEE); @@ -244,6 +280,7 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => { }); await mockNavigation(page, "employee"); await mockPortalHome(page, "employee"); + await mockAttendanceToday(page); await mockMe(page, ["employee"], "Employee"); await login(page, EMPLOYEE); diff --git a/issues/2026-04-12_r60-portal-workbench.csv b/issues/2026-04-12_r60-portal-workbench.csv new file mode 100644 index 00000000..d0bb4134 --- /dev/null +++ b/issues/2026-04-12_r60-portal-workbench.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R60 执行合同建档与范围冻结","创建并校验 R60 首页工作台优化执行合同 CSV,冻结范围:1) 后端扩展 portal/home 返回 recentActivities 与 teamStatusBoard;2) 新增 GET /api/portal/team-status 轻量端点;3) PortalHomePage 增加快捷操作栏、团队全员状态、近期动态流三个区块;4) MobilePortalPage 增加团队状态卡片与待办聚合。不新增 Prisma 迁移,所有数据从现有表聚合。","issues/2026-04-12_r60-portal-workbench.csv 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 R60,不混入其他模块。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-12_r60-portal-workbench.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 确认 12 列完整;2) A2-A6 依赖链符合 backend→frontend→reviewer→qa 收口;3) 无 Prisma 迁移。","manual","DONE","DONE","DONE","issues/2026-04-12_r60-portal-workbench.csv","[]","回滚路径:删除本次 CSV 不影响业务。" +"A2","后端扩展 portal/home 与新增 team-status 端点","在 portal.js 扩展 GET /api/portal/home 返回两个新字段:1) recentActivities[]——从 auditLog 聚合最近 15 条业务动态(报销提交/审批、请假提交/审批、考勤异常、资产变动等),每条含 type/actor/summary/timestamp/href;2) teamStatusBoard[]——全员今日状态列表(id/name/avatarUrl/presenceStatus/attendanceStatus/leaveInfo)。新增 GET /api/portal/team-status 独立端点返回 teamStatusBoard,供移动端单独刷新。两个端点均需认证,team-status 端点不依赖 portalHome 特性开关。","1) GET /api/portal/home 响应新增 recentActivities(数组,每项含 id/type/actorName/summary/createdAt/href)和 teamStatusBoard(数组,每项含 employeeId/name/avatarUrl/presenceStatus/attendanceClockIn/attendanceClockOut/onLeave/leaveType);2) GET /api/portal/team-status 独立返回 teamStatusBoard,响应结构与 portal/home 中一致;3) recentActivities 从 auditLog 表按 created_at DESC 取最近 15 条,过滤 action 在 [reimbursement.submit, reimbursement.approve, reimbursement.reject, leave.submit, leave.approve, leave.reject, attendance.clock_in, attendance.clock_out, attendance.supplement, asset.assign, asset.return] 范围内;4) teamStatusBoard 聚合 employee(active)+ 今日 presenceHistory + 今日 attendanceRecord + 当日 leaveRequest 状态;5) 不新增 Prisma 迁移。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/portal.js && npm test -- server/tests/unit/routes/portal.test.js 2>/dev/null; echo done。业务:1) 调用 GET /api/portal/home,检查响应含 recentActivities 和 teamStatusBoard 字段;2) 调用 GET /api/portal/team-status,检查返回全员状态数组;3) recentActivities 按时间倒序、不超过 15 条;4) teamStatusBoard 包含所有 active 员工。","manual","DONE","DONE","DONE","server/src/routes/portal.js|server/tests/unit/routes/portal.test.js","[""A1""]","回滚路径:移除 portal.js 新增的 recentActivities/teamStatusBoard 聚合逻辑与 team-status 路由。无 Prisma 迁移,回退仅需代码回滚。auditLog action 值需与现有审计日志 action 字段实际值对齐,实现时先查 schema 确认。" +"A3","PortalHomePage 快捷操作栏与团队全员状态","重构 PortalHomePage 布局,在原有 Hero 区域下方新增两个区块:1) 快捷操作栏——一排图标按钮(提交请假、提交报销、打卡、提交周报),点击跳转对应页面或触发打卡操作;2) 团队全员状态面板——展示 teamStatusBoard 全员头像+姓名+今日状态(在岗/请假/外出/未打卡等),<10 人一屏展示无需分页,每人显示 avatar、name、presenceStatus badge、attendanceStatus、leaveInfo。使用 Tailwind grid 布局,响应式适配移动端。","1) 首页 Hero 下方出现快捷操作栏,含至少 4 个操作按钮(请假/报销/打卡/周报),各按钮点击跳转正确路径;2) 团队全员状态面板展示所有 active 员工,每人显示头像(或首字 fallback)、姓名、出勤状态 badge(在岗绿/请假橙/外出蓝/未打卡灰);3) 考勤打卡按钮调用 POST /api/attendance/clock-in 或 clock-out(根据今日状态判断),成功后刷新状态;4) 页面首屏加载时请求 portal/home 一次即获取所有数据,不额外请求 team-status。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 登录后访问 /portal,确认快捷操作栏可见且各按钮可点击跳转;2) 团队状态面板显示全员,badge 颜色符合状态;3) 打卡按钮调用正确 API 并刷新显示。","manual","DONE","DONE","DOING","client/src/pages/PortalHomePage.tsx|client/src/api/portal.ts","[""A2""]","回滚路径:恢复 PortalHomePage 原有布局,移除快捷操作栏与团队状态面板。打卡操作复用 R59 已有的 attendance API,不新增端点。" +"A4","PortalHomePage 待办聚合与近期动态流","在 PortalHomePage 继续新增两个区块:1) 今日待办聚合——将待审批任务、逾期 TODO、合同到期预警等散落在各模块的待办事项合并展示,每条含图标+标题+来源模块+操作按钮(跳转),按优先级排序,最多展示 10 条;2) 近期动态流——展示 recentActivities,每条显示操作人头像+操作描述+时间戳,可点击跳转详情页。两个区块放在团队状态面板下方,使用 card 容器。","1) 今日待办聚合区块可见,从 portal/home 现有的 pendingTasks/todoSummary/cards 中提取并合并展示,每条可点击跳转;2) 近期动态流区块可见,展示 recentActivities 列表,每条含操作人名、操作摘要、相对时间(如'2小时前')、可点击跳转 href;3) 动态流为空时显示'暂无近期动态'占位;4) 待办为空时显示'暂无待办事项,休息一下吧'占位。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 登录后 /portal 可见待办聚合和动态流两个区块;2) 待办条目点击可跳转到正确页面;3) 动态流显示最近业务操作,时间格式友好。","manual","DONE","DONE","DOING","client/src/pages/PortalHomePage.tsx","[""A3""]","回滚路径:移除待办聚合与动态流区块,保留 A3 的快捷操作和团队状态。相对时间格式化可用简单工具函数,不引入 dayjs/moment。" +"A5","MobilePortalPage 团队状态与待办聚合同步","在 MobilePortalPage 新增两个区块:1) 团队状态卡片——在考勤打卡区域下方展示全员今日状态,紧凑的横向滚动或 grid 布局,显示头像+姓名+状态 badge;2) 待办聚合——合并审批待办与 TODO 展示前 5 条,每条可点击跳转。团队状态使用 GET /api/portal/team-status 端点获取,支持下拉刷新。","1) MobilePortalPage 考勤区域下方出现团队状态卡片,展示全员头像+姓名+状态;2) 团队状态下方出现待办聚合,展示前 5 条待办,每条可点击;3) 团队状态卡片调用 GET /api/portal/team-status(非 portal/home),减少移动端数据传输;4) 下拉或点击刷新可更新团队状态。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 访问 /mobile,考勤区下方可见团队状态和待办聚合;2) 点击待办跳转正确页面;3) 团队状态数据来自 /api/portal/team-status 端点。","manual","DONE","DONE","DOING","client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts","[""A2"",""A4""]","回滚路径:移除 MobilePortalPage 新增的团队状态和待办区块,恢复原布局。" +"A6","QA、状态回填与提交收口","执行 R60 post-check 与业务回归,回填 CSV 状态并完成提交收口,确保首页工作台新功能无回归。","后端与前端校验全部通过;快捷操作、团队状态、待办聚合、动态流、移动端同步回归完成;issues/2026-04-12_r60-portal-workbench.csv 的 Dev_Status、Review1_Status、Regression_Status 按测试结果回填。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && node --check src/routes/portal.js && npm test -- server/tests/unit/routes/portal.test.js 2>/dev/null && cd ../client && npm run typecheck && npm run build。业务:1) /portal 首页可见快捷操作栏、团队状态、待办聚合、动态流四个新区块;2) 各操作按钮功能正常;3) /mobile 可见团队状态和待办聚合;4) 不影响原有 portal 卡片、公告、快捷入口等功能。","manual","DOING","DOING","DOING","issues/2026-04-12_r60-portal-workbench.csv|server/src/routes/portal.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx","[""A5""]","回滚路径:若 QA 发现关键回归,暂停提交并回退对应代码变更。" diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js index 222ffc77..85472578 100644 --- a/server/src/routes/portal.js +++ b/server/src/routes/portal.js @@ -8,6 +8,9 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF const { getWorkspaceSetting } = require("../services/workspaceSettings"); const { currentDateString, parseDateOnlyString } = require("../utils/date"); +const PORTAL_RECENT_ACTIVITY_LIMIT = 15; +const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24; + async function buildTaskVisibilityFilters(tx, request) { const roleFilters = (request.employee.roleCodes ?? []).length ? [{ assignee_role: { in: request.employee.roleCodes } }] @@ -108,6 +111,277 @@ function buildQuickLinks({ request, featureEnabled }) { return items.slice(0, 8); } +function buildAvatarUrl(employeeId, avatarPath) { + return avatarPath ? `/api/avatars/${employeeId}` : null; +} + +function normalizeWorkflowActivityType(entityType, action) { + if (entityType === "reimbursement" && action === "start") return "reimbursement.submit"; + if (entityType === "reimbursement" && action === "approve") return "reimbursement.approve"; + if (entityType === "reimbursement" && action === "reject") return "reimbursement.reject"; + if (entityType === "leave" && action === "start") return "leave.submit"; + if (entityType === "leave" && action === "approve") return "leave.approve"; + if (entityType === "leave" && action === "reject") return "leave.reject"; + return null; +} + +function buildActivityHref(type, entityId) { + if (type.startsWith("reimbursement.")) { + return entityId ? `/reimbursements/${entityId}` : "/reimbursements"; + } + if (type.startsWith("leave.")) { + return "/leave-requests"; + } + if (type.startsWith("attendance.")) { + return "/attendance"; + } + if (type.startsWith("asset.")) { + return "/assets"; + } + return "/portal"; +} + +function buildWorkflowActivitySummary(type, actorName) { + if (type === "reimbursement.submit") return `${actorName} 提交了报销单`; + if (type === "reimbursement.approve") return `${actorName} 通过了报销单`; + if (type === "reimbursement.reject") return `${actorName} 驳回了报销单`; + if (type === "leave.submit") return `${actorName} 提交了请假单`; + if (type === "leave.approve") return `${actorName} 通过了请假单`; + if (type === "leave.reject") return `${actorName} 驳回了请假单`; + return `${actorName} 更新了流程记录`; +} + +function buildAuditActivitySummary(type, actorName, extra) { + const assetNo = extra?.assetNo ? ` ${extra.assetNo}` : ""; + if (type === "asset.assign") return `${actorName} 分配了资产${assetNo}`; + if (type === "asset.return") return `${actorName} 完成了资产归还${assetNo}`; + if (type === "attendance.supplement") return `${actorName} 补录了考勤`; + return `${actorName} 更新了业务记录`; +} + +function buildAttendanceActivitySummary(type, actorName) { + if (type === "attendance.clock_in") return `${actorName} 完成了上班打卡`; + if (type === "attendance.clock_out") return `${actorName} 完成了下班打卡`; + return `${actorName} 更新了考勤`; +} + +function sortActivitiesByCreatedAtDesc(left, right) { + return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); +} + +async function buildRecentActivities(prisma) { + const [workflowRows, auditRows, attendanceRows] = await Promise.all([ + prisma.workflowHistory.findMany({ + where: { + action: { in: ["start", "approve", "reject"] }, + workflow_instance: { + is: { + entity_type: { in: ["reimbursement", "leave"] }, + }, + }, + }, + orderBy: { created_at: "desc" }, + take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE, + select: { + id: true, + action: true, + created_at: true, + actor: { select: { name: true } }, + workflow_instance: { + select: { + entity_type: true, + entity_id: true, + }, + }, + }, + }), + prisma.auditLog.findMany({ + where: { + action: { in: ["ASSET_ASSIGNED", "ASSET_RETURNED", "ATTENDANCE_ADMIN_BACKFILLED"] }, + }, + orderBy: { created_at: "desc" }, + take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE, + select: { + id: true, + action: true, + target_id: true, + extra: true, + created_at: true, + actor: { select: { name: true } }, + }, + }), + prisma.attendanceRecord.findMany({ + where: { + OR: [ + { clock_in_at: { not: null } }, + { clock_out_at: { not: null } }, + ], + }, + orderBy: { updated_at: "desc" }, + take: PORTAL_RECENT_ACTIVITY_SOURCE_TAKE, + select: { + id: true, + clock_in_at: true, + clock_out_at: true, + employee_id: true, + employee: { select: { name: true } }, + }, + }), + ]); + + const workflowActivities = workflowRows + .map((row) => { + const type = normalizeWorkflowActivityType( + row.workflow_instance?.entity_type ?? null, + row.action, + ); + if (!type || !row.created_at) { + return null; + } + + const actorName = row.actor?.name ?? "系统"; + return { + id: `workflow-${row.id}`, + type, + actorName, + summary: buildWorkflowActivitySummary(type, actorName), + createdAt: row.created_at.toISOString(), + href: buildActivityHref(type, row.workflow_instance?.entity_id ?? null), + }; + }) + .filter(Boolean); + + const auditActionMap = { + ASSET_ASSIGNED: "asset.assign", + ASSET_RETURNED: "asset.return", + ATTENDANCE_ADMIN_BACKFILLED: "attendance.supplement", + }; + const auditActivities = auditRows + .map((row) => { + const type = auditActionMap[row.action]; + if (!type || !row.created_at) { + return null; + } + + const actorName = row.actor?.name ?? "系统"; + const extra = row.extra && typeof row.extra === "object" ? row.extra : {}; + return { + id: `audit-${row.id}`, + type, + actorName, + summary: buildAuditActivitySummary(type, actorName, extra), + createdAt: row.created_at.toISOString(), + href: buildActivityHref(type, row.target_id ?? null), + }; + }) + .filter(Boolean); + + const attendanceActivities = attendanceRows.flatMap((row) => { + const actorName = row.employee?.name ?? row.employee_id; + const items = []; + if (row.clock_in_at) { + items.push({ + id: `attendance-clock-in-${row.id}`, + type: "attendance.clock_in", + actorName, + summary: buildAttendanceActivitySummary("attendance.clock_in", actorName), + createdAt: row.clock_in_at.toISOString(), + href: buildActivityHref("attendance.clock_in"), + }); + } + if (row.clock_out_at) { + items.push({ + id: `attendance-clock-out-${row.id}`, + type: "attendance.clock_out", + actorName, + summary: buildAttendanceActivitySummary("attendance.clock_out", actorName), + createdAt: row.clock_out_at.toISOString(), + href: buildActivityHref("attendance.clock_out"), + }); + } + return items; + }); + + return [...workflowActivities, ...auditActivities, ...attendanceActivities] + .sort(sortActivitiesByCreatedAtDesc) + .slice(0, PORTAL_RECENT_ACTIVITY_LIMIT); +} + +async function buildTeamStatusBoard(prisma, dateString) { + const employees = await prisma.employee.findMany({ + where: { status: "active" }, + orderBy: [{ department_id: "asc" }, { employee_no: "asc" }], + select: { + id: true, + name: true, + avatar_path: true, + legal_entity_id: true, + }, + }); + + if (!employees.length) { + return []; + } + + const employeeIds = employees.map((item) => item.id); + const [presenceList, attendanceRows, leaveRows] = await Promise.all([ + Promise.all(employees.map((employee) => resolvePresenceForEmployee(prisma, employee.id, dateString))), + prisma.attendanceRecord.findMany({ + where: { + employee_id: { in: employeeIds }, + work_date: parseDateOnlyString(dateString), + }, + select: { + employee_id: true, + clock_in_at: true, + clock_out_at: true, + }, + }), + prisma.leaveRequest.findMany({ + where: { + owner_id: { in: employeeIds }, + status: "approved", + start_date: { lte: parseDateOnlyString(dateString) }, + end_date: { gte: parseDateOnlyString(dateString) }, + }, + orderBy: { created_at: "desc" }, + select: { + owner_id: true, + leave_type: { + select: { name: true }, + }, + }, + }), + ]); + + const attendanceByEmployeeId = new Map( + attendanceRows.map((row) => [row.employee_id, row]), + ); + const leaveByEmployeeId = new Map(); + for (const row of leaveRows) { + if (!leaveByEmployeeId.has(row.owner_id)) { + leaveByEmployeeId.set(row.owner_id, row); + } + } + + return employees.map((employee, index) => { + const attendance = attendanceByEmployeeId.get(employee.id) ?? null; + const leave = leaveByEmployeeId.get(employee.id) ?? null; + const presence = presenceList[index] ?? null; + + return { + employeeId: employee.id, + name: employee.name, + avatarUrl: buildAvatarUrl(employee.id, employee.avatar_path), + presenceStatus: presence?.status ?? null, + attendanceClockIn: attendance?.clock_in_at?.toISOString?.() ?? null, + attendanceClockOut: attendance?.clock_out_at?.toISOString?.() ?? null, + onLeave: Boolean(leave), + leaveType: leave?.leave_type?.name ?? null, + }; + }); +} + function buildAdoptionHints({ activeEmployeeCount, login7dCount, weeklySummary, teamPresenceSummary, offboardingCount, assetReturnPendingCount }) { const hints = []; if (login7dCount < activeEmployeeCount) { @@ -173,6 +447,8 @@ async function portalRoutes(fastify) { openProjectRiskCount, projectFollowUpDueTodayCount, closurePendingProjectCount, + recentActivities, + teamStatusBoard, ] = await Promise.all([ fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }), fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }), @@ -280,6 +556,8 @@ async function portalRoutes(fastify) { select: { project_id: true }, }).then((items) => items.length) : Promise.resolve(0), + buildRecentActivities(fastify.prisma), + buildTeamStatusBoard(fastify.prisma, today), ]); const todayPresence = featureEnabled.presenceBoard @@ -470,8 +748,16 @@ async function portalRoutes(fastify) { } : null, adoptionSummary, + recentActivities, + teamStatusBoard, }); }); + + fastify.get("/portal/team-status", { preHandler: fastify.authenticate }, async (request, reply) => { + const today = currentDateString(fastify.config.appTimezone); + const teamStatusBoard = await buildTeamStatusBoard(fastify.prisma, today); + return sendData(reply, request, teamStatusBoard); + }); } module.exports = portalRoutes; diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js new file mode 100644 index 00000000..618e9ead --- /dev/null +++ b/server/tests/unit/routes/portal.test.js @@ -0,0 +1,469 @@ +const Fastify = require("fastify"); + +const { AppError } = require("../../../src/utils/AppError"); + +const featureFlagState = { + portalHome: true, + announcements: false, + presenceBoard: false, + weeklyReport: false, + employeeLifecycle: false, + todoBoard: false, + paymentBatch: false, + projectDomain: false, + projectCommunicationLog: false, + projectClosureChecklist: false, +}; + +vi.mock("../../../src/services/featureFlags", () => ({ + FEATURE_FLAG_KEYS: { + portalHome: "portal_home_v1", + announcements: "announcements_v1", + presenceBoard: "presence_board_v1", + weeklyReport: "weekly_report_v1", + employeeLifecycle: "employee_lifecycle_v1", + todoBoard: "todo_board_v1", + paymentBatch: "payment_batch_v1", + projectDomain: "project_domain_v1", + projectCommunicationLog: "project_communication_log_v1", + projectClosureChecklist: "project_closure_checklist_v1", + }, + evaluateFeatureFlag: vi.fn(async ({ flagKey }) => { + if (flagKey === "portal_home_v1") { + return { enabled: featureFlagState.portalHome, reason: featureFlagState.portalHome ? "enabled" : "force_off" }; + } + if (flagKey === "announcements_v1") return { enabled: featureFlagState.announcements, reason: "mock" }; + if (flagKey === "presence_board_v1") return { enabled: featureFlagState.presenceBoard, reason: "mock" }; + if (flagKey === "weekly_report_v1") return { enabled: featureFlagState.weeklyReport, reason: "mock" }; + if (flagKey === "employee_lifecycle_v1") return { enabled: featureFlagState.employeeLifecycle, reason: "mock" }; + if (flagKey === "todo_board_v1") return { enabled: featureFlagState.todoBoard, reason: "mock" }; + if (flagKey === "payment_batch_v1") return { enabled: featureFlagState.paymentBatch, reason: "mock" }; + if (flagKey === "project_domain_v1") return { enabled: featureFlagState.projectDomain, reason: "mock" }; + if (flagKey === "project_communication_log_v1") return { enabled: featureFlagState.projectCommunicationLog, reason: "mock" }; + if (flagKey === "project_closure_checklist_v1") return { enabled: featureFlagState.projectClosureChecklist, reason: "mock" }; + return { enabled: false, reason: "mock" }; + }), +})); + +vi.mock("../../../src/services/workspaceSettings", () => ({ + getWorkspaceSetting: vi.fn(async () => ({ + id: "workspace-1", + persisted: true, + profile: "small_team_internal", + })), +})); + +const resolvePresenceForEmployee = vi.fn(); +const buildTeamPresenceSummary = vi.fn(async () => ({ + office: 0, + remote: 0, + out: 0, + travel: 0, + leave: 0, + unspecified: 0, + total: 0, +})); + +vi.mock("../../../src/services/presenceHistory", () => ({ + resolvePresenceForEmployee, + buildTeamPresenceSummary, +})); + +const portalRoutes = require("../../../src/routes/portal"); + +function buildAttendanceRow(overrides = {}) { + return { + id: "attendance-1", + employee_id: "employee-1", + clock_in_at: new Date("2026-04-12T01:15:00.000Z"), + clock_out_at: null, + employee: { name: "张三" }, + ...overrides, + }; +} + +function buildWorkflowRow(overrides = {}) { + return { + id: "history-1", + action: "start", + created_at: new Date("2026-04-12T02:00:00.000Z"), + actor: { name: "李四" }, + workflow_instance: { + entity_type: "reimbursement", + entity_id: "reimbursement-1", + }, + ...overrides, + }; +} + +function buildAuditRow(overrides = {}) { + return { + id: "audit-1", + action: "ASSET_ASSIGNED", + target_id: "asset-1", + extra: { assetNo: "AS-001" }, + created_at: new Date("2026-04-12T03:00:00.000Z"), + actor: { name: "王五" }, + ...overrides, + }; +} + +function buildEmployeeRow(overrides = {}) { + return { + id: "employee-1", + employee_no: "E001", + name: "张三", + avatar_path: "avatars/zs.png", + legal_entity_id: "legal-1", + status: "active", + ...overrides, + }; +} + +function buildLeaveRow(overrides = {}) { + return { + owner_id: "employee-2", + start_date: new Date("2026-04-12T00:00:00.000Z"), + end_date: new Date("2026-04-12T00:00:00.000Z"), + reason: "休假", + status: "approved", + leave_type: { name: "年假" }, + ...overrides, + }; +} + +function buildPresenceRow(overrides = {}) { + return { + id: "presence-1", + employee_id: "employee-1", + date: new Date("2026-04-12T00:00:00.000Z"), + status: "office", + time_range: "full_day", + note: null, + source: "manual", + updated_at: new Date("2026-04-12T00:00:00.000Z"), + ...overrides, + }; +} + +async function buildApp(options = {}) { + const workflowHistoryRows = options.workflowHistoryRows ?? []; + const auditLogRows = options.auditLogRows ?? []; + const attendanceRows = options.attendanceRows ?? []; + const employees = options.employees ?? [buildEmployeeRow()]; + const leaveRows = options.leaveRows ?? []; + const employeePresenceRows = options.employeePresenceRows + ?? Object.entries(options.presenceByEmployeeId ?? {}) + .filter(([, value]) => value && value.status && value.status !== "leave" && value.status !== "travel") + .map(([employeeId, value], index) => + buildPresenceRow({ + id: `presence-${index + 1}`, + employee_id: employeeId, + status: value.status, + }), + ); + const travelRows = options.travelRows ?? []; + const permissionCodes = options.permissionCodes ?? ["expense.view_own"]; + const employeeId = options.employeeId ?? "employee-1"; + + resolvePresenceForEmployee.mockImplementation(async (_prisma, targetEmployeeId) => { + return options.presenceByEmployeeId?.[targetEmployeeId] ?? null; + }); + + const prisma = { + workflowTask: { + count: vi.fn(async () => 0), + }, + reimbursementForm: { + count: vi.fn(async () => 2), + }, + leaveRequest: { + count: vi.fn(async () => 1), + findMany: vi.fn(async ({ where }) => { + if (!where?.owner_id?.in) { + return []; + } + return leaveRows.filter((row) => where.owner_id.in.includes(row.owner_id)); + }), + }, + assetCard: { + count: vi.fn(async () => 1), + }, + employee: { + count: vi.fn(async () => employees.length), + findMany: vi.fn(async () => employees), + }, + employeePresence: { + findMany: vi.fn(async ({ where }) => + employeePresenceRows.filter((row) => { + if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) { + return false; + } + if (where?.date?.gte && row.date < where.date.gte) { + return false; + } + if (where?.date?.lte && row.date > where.date.lte) { + return false; + } + return true; + }), + ), + }, + authSession: { + findMany: vi.fn(async () => []), + }, + featureFlag: { + findUnique: vi.fn(async ({ where }) => { + if (where.flag_key !== "portal_home_v1") { + return null; + } + return { + id: "flag-portal-home", + flag_key: "portal_home_v1", + override_mode: "force_on", + rollout_type: "global", + department_ids: [], + note: "", + version: 1, + created_at: new Date("2026-04-01T00:00:00.000Z"), + created_by: null, + updated_at: new Date("2026-04-01T00:00:00.000Z"), + updated_by: null, + }; + }), + }, + workspaceSetting: { + findFirst: vi.fn(async () => ({ + id: "workspace-1", + profile: "small_team_internal", + org_mode: "single_company", + primary_entry_mode: "password", + notification_strategy: "in_app", + device_priority: "desktop", + module_visibility_json: {}, + created_at: new Date("2026-04-01T00:00:00.000Z"), + updated_at: new Date("2026-04-01T00:00:00.000Z"), + updated_by: null, + })), + }, + workflowHistory: { + findMany: vi.fn(async () => workflowHistoryRows), + }, + auditLog: { + findMany: vi.fn(async () => auditLogRows), + }, + attendanceRecord: { + findMany: vi.fn(async ({ where }) => { + if (where?.employee_id?.in) { + return attendanceRows.filter((row) => where.employee_id.in.includes(row.employee_id)); + } + return attendanceRows; + }), + }, + travelRequest: { + findMany: vi.fn(async ({ where }) => { + if (!where?.applicant_employee_id?.in) { + return []; + } + return travelRows.filter((row) => where.applicant_employee_id.in.includes(row.applicant_employee_id)); + }), + }, + }; + + const app = Fastify(); + app.decorate("prisma", prisma); + app.decorate("config", { + appTimezone: "Asia/Shanghai", + portalHomeEnabled: true, + announcementsEnabled: false, + presenceBoardEnabled: false, + weeklyReportEnabled: false, + employeeLifecycleEnabled: false, + todoBoardEnabled: false, + paymentBatchEnabled: false, + projectDomainEnabled: false, + projectCommunicationLogEnabled: false, + projectClosureChecklistEnabled: false, + }); + app.decorate("authenticate", async (request) => { + if (!request.headers.authorization) { + throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); + } + + request.employee = { + id: employeeId, + name: "测试用户", + department_id: "department-1", + permissionCodes, + roleCodes: [], + }; + }); + app.decorate( + "hasPermission", + (employee, permissionCode) => + (employee?.permissionCodes ?? []).includes("*") || + (employee?.permissionCodes ?? []).includes(permissionCode), + ); + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + return reply.status(error.statusCode).send({ + requestId: request.id, + error: { + code: error.code, + message: error.message, + details: error.details ?? {}, + }, + }); + } + + return reply.status(500).send({ + requestId: request.id, + error: { code: "SYS_001", message: "服务器内部错误", details: {} }, + }); + }); + + await app.register(portalRoutes, { prefix: "/api" }); + return { app, prisma }; +} + +describe("portal routes", () => { + beforeEach(() => { + featureFlagState.portalHome = true; + featureFlagState.announcements = false; + featureFlagState.presenceBoard = false; + featureFlagState.weeklyReport = false; + featureFlagState.employeeLifecycle = false; + featureFlagState.todoBoard = false; + featureFlagState.paymentBatch = false; + featureFlagState.projectDomain = false; + featureFlagState.projectCommunicationLog = false; + featureFlagState.projectClosureChecklist = false; + resolvePresenceForEmployee.mockReset(); + buildTeamPresenceSummary.mockClear(); + }); + + it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => { + const recentWorkflowRows = Array.from({ length: 8 }, (_, index) => + buildWorkflowRow({ + id: `history-${index + 1}`, + action: index % 2 === 0 ? "approve" : "start", + created_at: new Date(Date.UTC(2026, 3, 12, 10, index, 0)), + actor: { name: `流程用户${index + 1}` }, + workflow_instance: { + entity_type: index % 3 === 0 ? "leave" : "reimbursement", + entity_id: `entity-${index + 1}`, + }, + }), + ); + const recentAuditRows = Array.from({ length: 4 }, (_, index) => + buildAuditRow({ + id: `audit-${index + 1}`, + action: index === 0 ? "ATTENDANCE_ADMIN_BACKFILLED" : index % 2 === 0 ? "ASSET_ASSIGNED" : "ASSET_RETURNED", + created_at: new Date(Date.UTC(2026, 3, 12, 11, index, 0)), + actor: { name: `审计用户${index + 1}` }, + extra: { assetNo: `AS-${index + 1}` }, + }), + ); + const recentAttendanceRows = Array.from({ length: 4 }, (_, index) => + buildAttendanceRow({ + id: `attendance-${index + 1}`, + employee_id: `employee-${index + 1}`, + employee: { name: `打卡用户${index + 1}` }, + clock_in_at: new Date(Date.UTC(2026, 3, 12, 12, index, 0)), + clock_out_at: new Date(Date.UTC(2026, 3, 12, 13, index, 0)), + }), + ); + const employees = [ + buildEmployeeRow({ id: "employee-1", name: "张三" }), + buildEmployeeRow({ id: "employee-2", name: "李四", avatar_path: null }), + ]; + const { app } = await buildApp({ + workflowHistoryRows: recentWorkflowRows, + auditLogRows: recentAuditRows, + attendanceRows: recentAttendanceRows, + employees, + leaveRows: [buildLeaveRow({ owner_id: "employee-2", leave_type: { name: "年假" } })], + presenceByEmployeeId: { + "employee-1": { status: "office" }, + "employee-2": { status: "leave" }, + }, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/portal/home", + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + const payload = response.json().data; + expect(payload.recentActivities).toHaveLength(15); + expect(payload.recentActivities[0]).toMatchObject({ + type: "attendance.clock_out", + actorName: "打卡用户4", + href: "/attendance", + }); + expect(payload.recentActivities.map((item) => item.createdAt)).toEqual( + [...payload.recentActivities.map((item) => item.createdAt)].sort((left, right) => new Date(right).getTime() - new Date(left).getTime()), + ); + expect(payload.recentActivities.map((item) => item.type)).toContain("reimbursement.submit"); + expect(payload.recentActivities.map((item) => item.type)).toContain("leave.approve"); + expect(payload.recentActivities.map((item) => item.type)).toContain("asset.assign"); + expect(payload.recentActivities.map((item) => item.type)).toContain("attendance.supplement"); + expect(payload.teamStatusBoard).toEqual([ + { + employeeId: "employee-1", + name: "张三", + avatarUrl: "/api/avatars/employee-1", + presenceStatus: "office", + attendanceClockIn: "2026-04-12T12:00:00.000Z", + attendanceClockOut: "2026-04-12T13:00:00.000Z", + onLeave: false, + leaveType: null, + }, + { + employeeId: "employee-2", + name: "李四", + avatarUrl: null, + presenceStatus: "leave", + attendanceClockIn: "2026-04-12T12:01:00.000Z", + attendanceClockOut: "2026-04-12T13:01:00.000Z", + onLeave: true, + leaveType: "年假", + }, + ]); + + await app.close(); + }); + + it("allows /portal/team-status even when portal_home_v1 is disabled", async () => { + featureFlagState.portalHome = false; + const { app } = await buildApp({ + employees: [buildEmployeeRow({ id: "employee-1", name: "张三" })], + attendanceRows: [buildAttendanceRow({ employee_id: "employee-1" })], + presenceByEmployeeId: { "employee-1": { status: "office" } }, + }); + + const response = await app.inject({ + method: "GET", + url: "/api/portal/team-status", + headers: { authorization: "Bearer test" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual([ + { + employeeId: "employee-1", + name: "张三", + avatarUrl: "/api/avatars/employee-1", + presenceStatus: "office", + attendanceClockIn: "2026-04-12T01:15:00.000Z", + attendanceClockOut: null, + onLeave: false, + leaveType: null, + }, + ]); + + await app.close(); + }); +});