diff --git a/client/src/api/portal.ts b/client/src/api/portal.ts index 41d12072..d63c2a69 100644 --- a/client/src/api/portal.ts +++ b/client/src/api/portal.ts @@ -28,6 +28,15 @@ export type PortalHomePayload = { weeklyReport: { weekStart: string; status: "draft" | "submitted"; needsHelp: boolean; href: string } | null; weeklySummary: { weekStart: string; submittedCount: number; missingCount: number; needsHelpCount: number; href: string } | null; todoSummary: { openCount: number; highPriorityCount: number; href: string } | null; + projectSummary: + | { + myCount: number; + overdueMilestonesCount: number; + highPriorityTaskCount: number; + openRiskCount: number; + href: string; + } + | null; peopleLifecycle: { onboardingCount: number; offboardingCount: number; assetReturnPendingCount: number; href: string } | null; adoptionSummary: | { diff --git a/client/src/api/search.ts b/client/src/api/search.ts index 174a538a..86b57440 100644 --- a/client/src/api/search.ts +++ b/client/src/api/search.ts @@ -9,6 +9,9 @@ export type GlobalSearchHit = { | "purchase_request" | "travel_request" | "asset" + | "project" + | "contract_registry" + | "confidential_doc_ref" | "weekly_report" | "todo"; entityId: string; diff --git a/client/src/components/GlobalSearchDialog.tsx b/client/src/components/GlobalSearchDialog.tsx index ab7c5f7d..ae0e0478 100644 --- a/client/src/components/GlobalSearchDialog.tsx +++ b/client/src/components/GlobalSearchDialog.tsx @@ -18,6 +18,9 @@ function entityLabel(entityType: string) { if (entityType === "purchase_request") return "采购"; if (entityType === "travel_request") return "差旅"; if (entityType === "asset") return "资产"; + if (entityType === "project") return "项目"; + if (entityType === "contract_registry") return "合同台账"; + if (entityType === "confidential_doc_ref") return "资料引用"; return entityType; } @@ -32,7 +35,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp }); const message = useMemo(() => { - if (!trimmed) return "输入姓名、公告、资产、请假、报销、周报或待办关键字开始搜索。"; + if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或待办关键字开始搜索。"; if (trimmed.length < 2) return "至少输入 2 个字符。"; if (searchQuery.isError) { return isApiError(searchQuery.error) @@ -51,7 +54,7 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
setKeyword(event.target.value)} /> diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 07b4fa61..681bc5f0 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -60,6 +60,7 @@ export function PortalHomePage() { ) : null} + {payload.projectSummary ? ( + + 项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount} + + ) : null} {payload.peopleLifecycle ? ( 人员闭环:入职 {payload.peopleLifecycle.onboardingCount},离职 {payload.peopleLifecycle.offboardingCount},资产待归还 {payload.peopleLifecycle.assetReturnPendingCount} diff --git a/server/src/routes/portal.js b/server/src/routes/portal.js index eb2d7e6b..793ac756 100644 --- a/server/src/routes/portal.js +++ b/server/src/routes/portal.js @@ -139,6 +139,9 @@ function buildQuickLinks({ request, featureEnabled }) { if (featureEnabled.todoBoard) { items.push({ key: "todo", title: "待办", href: "/todos" }); } + if (featureEnabled.projectDomain) { + items.push({ key: "projects", title: "项目", href: "/projects" }); + } if (request.employee.permissionCodes.includes("system.users") || request.employee.permissionCodes.includes("system.settings")) { items.push({ key: "people", title: "人员管理", href: "/people" }); items.push({ key: "workspace", title: "工作区设置", href: "/system/workspace" }); @@ -186,6 +189,7 @@ async function portalRoutes(fastify) { employeeLifecycle: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.employeeLifecycle), todoBoard: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.todoBoard), paymentBatch: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.paymentBatch), + projectDomain: await evaluateOptionalFlag(fastify, request, FEATURE_FLAG_KEYS.projectDomain), }; const [ @@ -202,6 +206,10 @@ async function portalRoutes(fastify) { assetReturnPendingCount, activeEmployeeCount, login7dCount, + myProjectCount, + overdueProjectMilestoneCount, + highPriorityProjectTaskCount, + openProjectRiskCount, ] = 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 }] } }), @@ -260,6 +268,69 @@ async function portalRoutes(fastify) { distinct: ["employee_id"], }).then((rows) => rows.length) : Promise.resolve(0), + featureEnabled.projectDomain + ? fastify.prisma.project.count({ + where: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") + ? {} + : { + OR: [ + { owner_employee_id: request.employee.id }, + { manager_employee_id: request.employee.id }, + { members: { some: { employee_id: request.employee.id, left_at: null } } }, + ], + }, + }) + : Promise.resolve(0), + featureEnabled.projectDomain + ? fastify.prisma.projectMilestone.count({ + where: { + status: { in: ["pending", "in_progress", "delayed"] }, + due_date: { lt: new Date() }, + project: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") + ? undefined + : { + OR: [ + { owner_employee_id: request.employee.id }, + { manager_employee_id: request.employee.id }, + { members: { some: { employee_id: request.employee.id, left_at: null } } }, + ], + }, + }, + }) + : Promise.resolve(0), + featureEnabled.projectDomain + ? fastify.prisma.projectTask.count({ + where: { + status: { in: ["todo", "doing", "blocked"] }, + priority: { in: ["high", "urgent"] }, + project: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") + ? undefined + : { + OR: [ + { owner_employee_id: request.employee.id }, + { manager_employee_id: request.employee.id }, + { members: { some: { employee_id: request.employee.id, left_at: null } } }, + ], + }, + }, + }) + : Promise.resolve(0), + featureEnabled.projectDomain + ? fastify.prisma.projectRisk.count({ + where: { + status: { not: "closed" }, + project: fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "*") + ? undefined + : { + OR: [ + { owner_employee_id: request.employee.id }, + { manager_employee_id: request.employee.id }, + { members: { some: { employee_id: request.employee.id, left_at: null } } }, + ], + }, + }, + }) + : Promise.resolve(0), ]); const todayPresence = featureEnabled.presenceBoard @@ -328,6 +399,10 @@ async function portalRoutes(fastify) { href: "/workflow/tasks", severity: pendingTaskCount > 0 ? "warning" : "normal", }); + if (featureEnabled.projectDomain) { + cards.push({ key: "project_milestones", title: "逾期里程碑", value: overdueProjectMilestoneCount, href: "/projects", severity: overdueProjectMilestoneCount > 0 ? "warning" : "normal" }); + cards.push({ key: "project_risks", title: "项目风险", value: openProjectRiskCount, href: "/projects", severity: openProjectRiskCount > 0 ? "high" : "normal" }); + } } else { cards.push({ key: "my_todos", @@ -353,6 +428,9 @@ async function portalRoutes(fastify) { href: "/weekly-reports", severity: myWeeklyReport?.status === "submitted" ? "normal" : "warning", }); + if (featureEnabled.projectDomain) { + cards.push({ key: "my_projects", title: "我的项目", value: myProjectCount, href: "/projects", severity: highPriorityProjectTaskCount > 0 ? "warning" : "normal" }); + } } if (featureEnabled.paymentBatch && riskCount > 0) { @@ -423,6 +501,15 @@ async function portalRoutes(fastify) { href: "/todos", } : null, + projectSummary: featureEnabled.projectDomain + ? { + myCount: myProjectCount, + overdueMilestonesCount: overdueProjectMilestoneCount, + highPriorityTaskCount: highPriorityProjectTaskCount, + openRiskCount: openProjectRiskCount, + href: "/projects", + } + : null, peopleLifecycle: featureEnabled.employeeLifecycle && isManagerView ? { onboardingCount, diff --git a/server/src/routes/search.js b/server/src/routes/search.js index 5e12d7e2..7314b03a 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -13,12 +13,15 @@ const ENTITY_PRIORITY = { employee: 0, announcement: 1, asset: 2, - leave: 3, - reimbursement: 4, - weekly_report: 5, - todo: 6, - purchase_request: 7, - travel_request: 8, + project: 3, + contract_registry: 4, + confidential_doc_ref: 5, + leave: 6, + reimbursement: 7, + weekly_report: 8, + todo: 9, + purchase_request: 10, + travel_request: 11, }; function buildValidationError(details, statusCode = 400) { @@ -75,6 +78,9 @@ async function searchRoutes(fastify) { announcements: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.announcements), weeklyReport: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.weeklyReport), todoBoard: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.todoBoard), + projectDomain: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain), + projectLinking: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking), + confidentialDocRefs: await isFlagEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs), }; const tasks = []; @@ -261,6 +267,98 @@ async function searchRoutes(fastify) { ); } + if (featureEnabled.projectDomain && (fastify.hasPermission(request.employee, "project.view_own") || fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage"))) { + tasks.push( + fastify.prisma.project.findMany({ + where: { + AND: [ + fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || isAdmin + ? {} + : { + OR: [ + { owner_employee_id: request.employee.id }, + { manager_employee_id: request.employee.id }, + { members: { some: { employee_id: request.employee.id, left_at: null } } }, + ], + }, + { + OR: [ + { project_no: { contains: q, mode: "insensitive" } }, + { name: { contains: q, mode: "insensitive" } }, + { client_name: { contains: q, mode: "insensitive" } }, + { client_alias: { contains: q, mode: "insensitive" } }, + ], + }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, project_no: true, name: true, client_name: true, client_alias: true, is_confidential: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "project", + entityId: item.id, + title: `${item.project_no} · ${item.name}`, + subtitle: item.is_confidential ? "涉密项目" : item.client_alias || item.client_name || null, + status: null, + href: `/projects/${item.id}`, + matchedBy: matchByValue(q, item.project_no, item.name, item.client_name, item.client_alias), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + if (featureEnabled.projectLinking && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) { + tasks.push( + fastify.prisma.contractRegistry.findMany({ + where: { + OR: [ + { contract_no: { contains: q, mode: "insensitive" } }, + { counterparty_name: { contains: q, mode: "insensitive" } }, + { summary: { contains: q, mode: "insensitive" } }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, contract_no: true, counterparty_name: true, confidentiality_level: true, summary: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "contract_registry", + entityId: item.id, + title: item.contract_no, + subtitle: ["confidential", "restricted"].includes(item.confidentiality_level) ? "涉密合同台账" : item.counterparty_name, + status: item.confidentiality_level, + href: "/contracts-registry", + matchedBy: matchByValue(q, item.contract_no, item.counterparty_name, item.summary), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + + if (featureEnabled.confidentialDocRefs && (fastify.hasPermission(request.employee, "project.view_all") || fastify.hasPermission(request.employee, "project.manage") || fastify.hasPermission(request.employee, "system.settings") || isAdmin)) { + tasks.push( + fastify.prisma.confidentialDocumentRef.findMany({ + where: { + OR: [ + { title: { contains: q, mode: "insensitive" } }, + { document_no: { contains: q, mode: "insensitive" } }, + { offline_archive_no: { contains: q, mode: "insensitive" } }, + ], + }, + take: limit, + orderBy: { updated_at: "desc" }, + select: { id: true, title: true, document_no: true, confidentiality_level: true, updated_at: true }, + }).then((rows) => rows.map((item) => ({ + entityType: "confidential_doc_ref", + entityId: item.id, + title: item.document_no || item.title, + subtitle: ["confidential", "restricted"].includes(item.confidentiality_level) ? "涉密资料引用" : item.title, + status: item.confidentiality_level, + href: "/contracts-registry", + matchedBy: matchByValue(q, item.title, item.document_no), + updatedAt: item.updated_at?.toISOString?.() ?? null, + }))), + ); + } + if (featureEnabled.todoBoard) { tasks.push( fastify.prisma.todoItem.findMany({ diff --git a/server/src/services/notifications/imEnqueue.js b/server/src/services/notifications/imEnqueue.js index 11ca7d5b..f9090927 100644 --- a/server/src/services/notifications/imEnqueue.js +++ b/server/src/services/notifications/imEnqueue.js @@ -75,6 +75,32 @@ function buildImDedupeKey({ notificationType, targetType, targetId, targetPath, ].join(':'); } +function redactImMessage(config, payload) { + const effectiveType = String(payload?.entityType ?? payload?.targetType ?? '').trim(); + const rawTitle = String(payload?.title ?? '系统通知').slice(0, 200); + const rawBody = payload?.body == null ? '' : String(payload.body); + + if (!config?.publicSecurityHardeningEnabled) { + return { title: rawTitle, body: rawBody.slice(0, 4000) }; + } + + if (effectiveType === 'project') { + return { + title: rawTitle || '项目待处理事项', + body: '', + }; + } + + if (['contract_registry', 'confidential_doc_ref'].includes(effectiveType)) { + return { + title: '有待处理事项', + body: `事项编号:${String(payload?.targetId ?? payload?.entityId ?? '-').slice(0, 80)}`, + }; + } + + return { title: rawTitle, body: rawBody.slice(0, 4000) }; +} + async function enqueueImNotifications(tx, params) { const { config, @@ -154,6 +180,14 @@ async function enqueueImNotifications(tx, params) { let queued = 0; let duplicated = 0; + const redactedMessage = redactImMessage(config, { + title, + body, + targetType, + targetId, + entityType, + entityId, + }); for (const channel of matchedChannels) { const result = await enqueueIntegrationEvent(tx, { eventType: 'notification.im', @@ -171,8 +205,8 @@ async function enqueueImNotifications(tx, params) { channelType: normalizeChannelType(channel.channel_type), notificationType, domainScope, - title: String(title ?? '').slice(0, 200), - body: body == null ? '' : String(body).slice(0, 4000), + title: redactedMessage.title, + body: redactedMessage.body, targetType: targetType ?? null, targetId: targetId ?? null, targetPath: targetPath ?? null,