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

这个提交包含在:
2026-04-12 12:30:14 +08:00
父节点 dbab1d7b6e
当前提交 3f8c5144c2
共修改 7 个文件,包含 1514 行新增和 14 行删除
+286
查看文件
@@ -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;