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,