R60 A1-A6:首页工作台优化

这个提交包含在:
2026-04-12 12:30:14 +08:00
父节点 dbab1d7b6e
当前提交 3f8c5144c2
共修改 7 个文件,包含 1514 行新增和 14 行删除
+37
查看文件
@@ -1,5 +1,36 @@
import { apiRequestData } from "./http"; 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 = { export type PortalHomePayload = {
workspace: { workspace: {
id: string | null; id: string | null;
@@ -55,8 +86,14 @@ export type PortalHomePayload = {
hints: string[]; hints: string[];
} }
| null; | null;
recentActivities: PortalRecentActivity[];
teamStatusBoard: PortalTeamStatusItem[];
}; };
export async function fetchPortalHome() { export async function fetchPortalHome() {
return apiRequestData<PortalHomePayload>("/api/portal/home"); return apiRequestData<PortalHomePayload>("/api/portal/home");
} }
export async function fetchPortalTeamStatus() {
return apiRequestData<PortalTeamStatusItem[]>("/api/portal/team-status");
}
+142 -8
查看文件
@@ -8,12 +8,14 @@ import {
fetchMyTodayAttendance, fetchMyTodayAttendance,
} from "../api/attendance"; } from "../api/attendance";
import { fetchNotifications } from "../api/notifications"; import { fetchNotifications } from "../api/notifications";
import { fetchPortalTeamStatus } from "../api/portal";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence"; import { fetchMyPresence, upsertMyPresence, type PresenceItem } from "../api/presence";
import { fetchWorkflowTasks } from "../api/workflow"; import { fetchWorkflowTasks } from "../api/workflow";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions"; import { hasAnyPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; 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) { function presenceStatusLabel(item: PresenceItem | null) {
if (!item) { if (!item) {
return "未更新"; return "未更新";
@@ -123,6 +140,50 @@ function resolveNotificationTargetPath(
return "/notifications"; 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({ function QuickLinkCard({
label, label,
description, description,
@@ -183,11 +244,13 @@ export function MobilePortalPage() {
const pendingCountQuery = useQuery({ const pendingCountQuery = useQuery({
enabled: canAccessApprovals, enabled: canAccessApprovals,
queryKey: ["mobile", "workflow", "pending-count"], queryKey: ["mobile", "workflow", "pending-items"],
queryFn: async () => { queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 5 }),
const response = await fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 1 }); });
return response.total;
}, const teamStatusQuery = useQuery({
queryKey: ["mobile", "portal", "team-status"],
queryFn: () => fetchPortalTeamStatus(),
}); });
const notificationsQuery = useQuery({ const notificationsQuery = useQuery({
@@ -249,6 +312,8 @@ export function MobilePortalPage() {
const presenceItem = presenceQuery.data?.item ?? null; const presenceItem = presenceQuery.data?.item ?? null;
const attendanceToday = attendanceTodayQuery.data ?? null; const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const teamStatusItems = teamStatusQuery.data ?? [];
const pendingTasks = pendingCountQuery.data?.items ?? [];
const notifications = notificationsQuery.data?.items ?? []; const notifications = notificationsQuery.data?.items ?? [];
return ( return (
@@ -333,6 +398,48 @@ export function MobilePortalPage() {
</section> </section>
) : null} ) : null}
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">团队状态</div>
<div className="mt-1 text-xs text-zinc-500">查看全员今日在岗、请假和打卡状态。</div>
</div>
<span className="badge-neutral">{teamStatusItems.length} 人</span>
</div>
{teamStatusQuery.isLoading ? <p className="text-sm text-zinc-500">加载团队状态中...</p> : null}
{teamStatusQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamStatusQuery.error)}
</p>
) : null}
{!teamStatusQuery.isLoading && !teamStatusQuery.isError && !teamStatusItems.length ? (
<p className="text-sm text-zinc-500">暂无团队状态。</p>
) : null}
<div className="grid grid-cols-2 gap-3">
{teamStatusItems.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center gap-2">
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-[11px] text-zinc-500">
{item.attendanceClockIn ? `${formatShortTime(item.attendanceClockIn)} 已打卡` : "暂无打卡"}
</div>
</div>
</div>
<div className="mt-2">
<span className={badge.className}>{badge.label}</span>
</div>
</div>
);
})}
</div>
</section>
<section className="card-std space-y-4 p-4"> <section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
@@ -369,20 +476,47 @@ export function MobilePortalPage() {
<section className="card-std space-y-3 p-4"> <section className="card-std space-y-3 p-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-zinc-900">待审批</div> <div className="text-sm font-semibold text-zinc-900">待办聚合</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
{canAccessApprovals ? "点击进入移动审批" : "当前账号没有移动审批权限"} {canAccessApprovals ? "展示前 5 条审批待办,可直接进入移动审批。" : "当前账号没有移动审批权限"}
</div> </div>
</div> </div>
<span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}> <span className={canAccessApprovals ? "badge-warning" : "badge-neutral"}>
{canAccessApprovals ? `${pendingCountQuery.data ?? 0} 条` : "不可用"} {canAccessApprovals ? `${pendingCountQuery.data?.total ?? 0} 条` : "不可用"}
</span> </span>
</div> </div>
{canAccessApprovals ? ( {canAccessApprovals ? (
<>
<Button asChild type="button" className="w-full"> <Button asChild type="button" className="w-full">
<Link to="/mobile/approvals">进入移动审批</Link> <Link to="/mobile/approvals">进入移动审批</Link>
</Button> </Button>
{pendingCountQuery.isLoading ? <p className="text-sm text-zinc-500">加载待办中...</p> : null}
{pendingCountQuery.isError ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(pendingCountQuery.error)}
</p>
) : null}
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
<p className="text-sm text-zinc-500">当前没有审批待办。</p>
) : null}
<div className="space-y-2">
{pendingTasks.map((task) => (
<Link
key={task.id}
to={buildMobileApprovalsPath(task.id)}
className="block rounded-2xl border border-zinc-200 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-zinc-50"
>
<div className="font-medium text-zinc-900">{task.stepName}</div>
<div className="mt-1 text-xs text-zinc-500">
业务:{workflowEntityLabel(task.workflowInstance?.entityType)} · 实体:{task.workflowInstance?.entityId ?? "-"}
</div>
</Link>
))}
</div>
</>
) : ( ) : (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500"> <div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-500">
你当前没有审批权限,审批页入口已禁用。 你当前没有审批权限,审批页入口已禁用。
+532 -2
查看文件
@@ -1,10 +1,23 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchPortalHome, type PortalHomePayload } from "../api/portal"; import { toast } from "sonner";
import {
clockInAttendance,
clockOutAttendance,
fetchMyTodayAttendance,
type AttendanceTodayResponse,
} from "../api/attendance";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import {
fetchPortalHome,
type PortalHomePayload,
type PortalRecentActivity,
type PortalTeamStatusItem,
} from "../api/portal";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, isManagerRole } from "../auth/permissions"; import { hasAnyPermission, isManagerRole } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { EmptyStateCard } from "../components/common/EmptyStateCard"; import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { MetricCard } from "../components/patterns/MetricCard"; import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
@@ -23,6 +36,32 @@ type SummaryItem = {
tone?: "normal" | "warning"; 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 { function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
return Boolean(item); return Boolean(item);
} }
@@ -47,6 +86,251 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
return payload.status === "submitted" ? "已提交" : "待填写"; 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<string, TodoAggregateItem>();
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[] }) { function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
if (!items.length) { if (!items.length) {
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />; return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
@@ -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 = (
<>
<div className="flex items-center gap-3">
<div className={disabled
? "flex size-10 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-400"
: "flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary"}
>
<span className="material-symbols-outlined text-[20px]">{icon}</span>
</div>
<div className="min-w-0">
<div className={disabled ? "font-medium text-zinc-500" : "font-medium text-zinc-900"}>{label}</div>
<div className="mt-1 text-xs text-zinc-500">{description}</div>
</div>
</div>
</>
);
if (to && !disabled) {
return <Link to={to} className={baseClassName}>{content}</Link>;
}
if (onClick) {
return (
<button type="button" className={baseClassName} disabled={disabled} onClick={onClick}>
{content}
</button>
);
}
return <div className={baseClassName}>{content}</div>;
}
function QuickActionsPanel({
items,
}: {
items: QuickActionConfig[];
}) {
return (
<SectionPanel title="快捷操作栏" description="把每天高频动作直接前置到首页,不再绕路。">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.length ? items.map((item) => (
<QuickActionTile
key={item.key}
icon={item.icon}
label={item.label}
description={item.description}
to={item.to}
onClick={item.onClick}
disabled={item.disabled}
/>
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
</div>
</SectionPanel>
);
}
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
return (
<SectionPanel title="今日待办聚合" description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。">
<div className="space-y-3">
{items.length ? items.map((item) => (
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
<div className="flex items-start gap-3">
<div className={item.severity === "high"
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-red-50 text-red-600"
: item.severity === "warning"
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-amber-50 text-amber-600"
: "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-600"}
>
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-zinc-900">{item.title}</div>
<span className="badge-neutral">{item.sourceLabel}</span>
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
</div>
<div className="mt-2 text-sm text-primary">进入处理</div>
</div>
</div>
</Link>
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
</div>
</SectionPanel>
);
}
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
return (
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{items.length ? items.map((item) => {
const badge = resolveTeamStatusBadge(item);
return (
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
<div className="flex items-center gap-3">
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-zinc-900">{item.name}</div>
<div className="mt-1 text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
</div>
<span className={badge.className}>{badge.label}</span>
</div>
</div>
);
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
</div>
</SectionPanel>
);
}
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
return (
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
<div className="space-y-3">
{items.length ? items.map((item) => (
<Link key={item.id} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
<div className="flex items-start gap-3">
<EmployeeAvatar name={item.actorName} size="sm" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
</div>
</div>
</Link>
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
</div>
</SectionPanel>
);
}
export function PortalHomePage() { export function PortalHomePage() {
const queryClient = useQueryClient();
const { permissions } = useAuth(); const { permissions } = useAuth();
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); 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 canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
const isManagerView = isManagerRole(permissions); 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) { if (portalQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>; return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
} }
@@ -219,6 +687,15 @@ export function PortalHomePage() {
const primaryCards = pickCards(payload.cards, portalSchema.heroCards); const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
const primaryCardKeySet = primaryCards.map((card) => card.key); const primaryCardKeySet = primaryCards.map((card) => card.key);
const secondaryCards = compactCards(payload.cards, primaryCardKeySet); 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 = ([ const employeeSummaryItems = ([
{ {
@@ -272,6 +749,49 @@ export function PortalHomePage() {
: null, : null,
] as Array<SummaryItem | null>).filter(isSummaryItem); ] as Array<SummaryItem | null>).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 ( return (
<PageScaffold> <PageScaffold>
<WorkbenchHero <WorkbenchHero
@@ -309,6 +829,16 @@ export function PortalHomePage() {
))} ))}
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<QuickActionsPanel items={quickActions} />
<TodoAggregatePanel items={todoAggregateItems} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
<TeamStatusBoardPanel items={payload.teamStatusBoard} />
<RecentActivitiesPanel items={payload.recentActivities} />
</div>
{isManagerView ? ( {isManagerView ? (
<> <>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
+38 -1
查看文件
@@ -11,7 +11,6 @@ function envelope(data: unknown) {
}; };
} }
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) { function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
return page.route("**/api/me", async (route) => { return page.route("**/api/me", async (route) => {
await route.fulfill({ 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") { function mockPortalHome(page: import("@playwright/test").Page, mode: "employee" | "manager") {
return page.route("**/api/portal/home", async (route) => { return page.route("**/api/portal/home", async (route) => {
const data = mode === "manager" 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" }, projectSummary: { myCount: 5, overdueMilestonesCount: 2, highPriorityTaskCount: 2, openRiskCount: 1, followUpDueTodayCount: 2, closurePendingProjectCount: 1, href: "/projects" },
peopleLifecycle: { onboardingCount: 1, offboardingCount: 0, assetReturnPendingCount: 1, href: "/people" }, 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 人未提交周报"] }, 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" }, 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" }, projectSummary: { myCount: 2, overdueMilestonesCount: 1, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 1, closurePendingProjectCount: 0, href: "/projects" },
peopleLifecycle: null, peopleLifecycle: null,
adoptionSummary: 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({ await route.fulfill({
@@ -175,6 +204,7 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await mockNavigation(page, "employee"); await mockNavigation(page, "employee");
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "Employee");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
@@ -195,11 +225,16 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
await expect(page.getByText("我的申请与资产")).toBeVisible(); 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();
await expect(page.getByRole("heading", { name: "近期动态流" })).toBeVisible();
}); });
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => { test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
await mockNavigation(page, "manager"); await mockNavigation(page, "manager");
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
await mockAttendanceToday(page);
await mockMe(page, ["admin"], "Admin"); await mockMe(page, ["admin"], "Admin");
await login(page, ADMIN); await login(page, ADMIN);
@@ -226,6 +261,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
test("Portal 域管理角色也进入经理视图", async ({ page }) => { test("Portal 域管理角色也进入经理视图", async ({ page }) => {
await mockNavigation(page, "manager"); await mockNavigation(page, "manager");
await mockPortalHome(page, "manager"); await mockPortalHome(page, "manager");
await mockAttendanceToday(page);
await mockMe(page, ["administration"], "Administration"); await mockMe(page, ["administration"], "Administration");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
@@ -244,6 +280,7 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
}); });
await mockNavigation(page, "employee"); await mockNavigation(page, "employee");
await mockPortalHome(page, "employee"); await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockMe(page, ["employee"], "Employee"); await mockMe(page, ["employee"], "Employee");
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
@@ -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 发现关键回归,暂停提交并回退对应代码变更。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 不影响业务。
3 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 确认。
4 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,不新增端点。
5 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。
6 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 新增的团队状态和待办区块,恢复原布局。
7 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 发现关键回归,暂停提交并回退对应代码变更。
+286
查看文件
@@ -8,6 +8,9 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
const { getWorkspaceSetting } = require("../services/workspaceSettings"); const { getWorkspaceSetting } = require("../services/workspaceSettings");
const { currentDateString, parseDateOnlyString } = require("../utils/date"); const { currentDateString, parseDateOnlyString } = require("../utils/date");
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
async function buildTaskVisibilityFilters(tx, request) { async function buildTaskVisibilityFilters(tx, request) {
const roleFilters = (request.employee.roleCodes ?? []).length const roleFilters = (request.employee.roleCodes ?? []).length
? [{ assignee_role: { in: request.employee.roleCodes } }] ? [{ assignee_role: { in: request.employee.roleCodes } }]
@@ -108,6 +111,277 @@ function buildQuickLinks({ request, featureEnabled }) {
return items.slice(0, 8); 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 }) { function buildAdoptionHints({ activeEmployeeCount, login7dCount, weeklySummary, teamPresenceSummary, offboardingCount, assetReturnPendingCount }) {
const hints = []; const hints = [];
if (login7dCount < activeEmployeeCount) { if (login7dCount < activeEmployeeCount) {
@@ -173,6 +447,8 @@ async function portalRoutes(fastify) {
openProjectRiskCount, openProjectRiskCount,
projectFollowUpDueTodayCount, projectFollowUpDueTodayCount,
closurePendingProjectCount, closurePendingProjectCount,
recentActivities,
teamStatusBoard,
] = await Promise.all([ ] = await Promise.all([
fastify.prisma.workflowTask.count({ where: { status: "pending", OR: assigneeFilters } }), 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 }] } }), 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 }, select: { project_id: true },
}).then((items) => items.length) }).then((items) => items.length)
: Promise.resolve(0), : Promise.resolve(0),
buildRecentActivities(fastify.prisma),
buildTeamStatusBoard(fastify.prisma, today),
]); ]);
const todayPresence = featureEnabled.presenceBoard const todayPresence = featureEnabled.presenceBoard
@@ -470,8 +748,16 @@ async function portalRoutes(fastify) {
} }
: null, : null,
adoptionSummary, 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; module.exports = portalRoutes;
+469
查看文件
@@ -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();
});
});