R60 A1-A6:首页工作台优化
这个提交包含在:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户