import { auditDict } from "./auditDict"; import { roleDict, getDictLabel, statusDict } from "../dictionaries"; import { TEXT } from "../locales"; export interface AuditEvent { eventType: string; eventLabel: string; actorId: string; actorName: string; actorRole: string; actorRoleLabel: string; targetType: string; targetTypeLabel: string; targetId: string; targetName?: string; actionText: string; before?: Record; after?: Record; diffText?: string[]; reason?: string; result: "SUCCESS" | "FAIL"; resultLabel: string; timestamp: string; } const entityTypeLabelMap: Record = { study_setup_config: "立项配置", study: "项目", subject: "参与者", visit: "访视", ae: "不良事件", finance_contract: "费用合同", contract_fee: "合同费用条目", contract_fee_payment: "合同费用回款", drug_shipment: "药品流向", startup_feasibility: "立项可行性", startup_ethics: "立项伦理", startup_kickoff: "启动会", training_authorization: "培训授权", knowledge_note: "注意事项", subject_history: "参与者历史", faq_category: "医学咨询分类", faq_item: "医学咨询问题", faq_reply: "医学咨询回复", DOCUMENT: "文档", DOCUMENT_VERSION: "文档版本", DISTRIBUTION: "文档分发", ACKNOWLEDGEMENT: "文档签收", }; const setupModuleLabelMap: Record = { projectMilestones: "项目里程碑", enrollmentPlan: "项目入组计划", siteMilestones: "中心里程碑", siteEnrollmentPlans: "中心入组计划", centerConfirm: "中心确认", }; const toReadableEntityType = (value?: string): string => { const key = String(value || "").trim(); if (!key) return TEXT.audit.targetFallback; return entityTypeLabelMap[key] || key; }; const normalizeSetupModuleText = (raw: string): string => { const text = String(raw || "").trim(); if (!text) return "无"; return text .split(/[,,]/) .map((item) => item.trim()) .filter(Boolean) .map((item) => setupModuleLabelMap[item] || item) .join("、") || "无"; }; const normalizeLegacyDetailLine = (line: string): string => { const text = String(line || "").trim(); if (!text) return ""; const projectionStatusMatch = text.match(/^projection_status=(.+)$/i); if (projectionStatusMatch) { const status = projectionStatusMatch[1].trim().toLowerCase(); const statusLabel = status === "success" ? "成功" : status === "failed" ? "失败" : status; return `发布同步结果:${statusLabel}`; } const studyUpdatedMatch = text.match(/^study_updated=(.+)$/i); if (studyUpdatedMatch) { const rawValue = studyUpdatedMatch[1].trim().toLowerCase(); return `已同步项目主信息:${rawValue === "true" ? "是" : rawValue === "false" ? "否" : rawValue}`; } const siteUpdatedMatch = text.match(/^site_updated=(\d+)$/i); if (siteUpdatedMatch) return `已同步中心数:${siteUpdatedMatch[1]}`; const siteSkippedMatch = text.match(/^site_skipped=(\d+)$/i); if (siteSkippedMatch) return `未同步中心数:${siteSkippedMatch[1]}`; const warningsMatch = text.match(/^warnings=(.*)$/i); if (warningsMatch) { const value = warningsMatch[1].trim(); return `同步告警:${value === "-" ? "无" : value}`; } const skippedReasonMatch = text.match(/^skipped_reason_counts=(.*)$/i); if (skippedReasonMatch) { const value = skippedReasonMatch[1].trim(); return `未同步原因统计:${value === "-" ? "无" : value}`; } const setupModuleMatch = text.match(/^变更模块[::]\s*(.*)$/); if (setupModuleMatch) { return `变更模块:${normalizeSetupModuleText(setupModuleMatch[1])}`; } const detailMatch = text.match(/^变更明细[::]\s*(.*)$/); if (detailMatch) { const detailText = detailMatch[1].trim(); return `变更明细:${detailText || "无"}`; } if (text === "立项配置已发布") return "立项配置已发布"; if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划"); return text; }; const splitAuditDetailSegments = (line: string): string[] => { const text = String(line || "").trim(); if (!text) return []; return text .split(/[;;\n]/) .map((item) => item.trim()) .filter(Boolean); }; const auditFieldLabelMap: Record = { issue_no: "问题编号", source: "问题来源", monitor_type: "监查类型", monitor_item: "监查项", category: "问题分类", recommendation: "建议措施", subject_name: "受试者", subject_code: "受试者缩写号", description: "问题描述", action_taken: "采取措施", follow_up_progress: "跟进计划及进展", found_date: "发现时间", due_at: "预计解决日期", actual_resolve_date: "实际解决日期", closed_at: "关闭日期", responsible_name: "责任人", creator_name: "创建者", status: "状态", is_active: "启用状态", role: "角色", role_in_study: "项目角色", full_name: "姓名", username: "账号", clinical_department: "科室", email: "邮箱", name: "名称", code: "编号", site_name: "中心名称", pi_name: "PI", city: "城市", contact: "负责人", phone: "电话", plan_start_date: "计划开始日期", plan_end_date: "计划结束日期", }; const stableSerialize = (value: any): string => { if (value === null || value === undefined) return ""; if (Array.isArray(value)) return `[${value.map((item) => stableSerialize(item)).join(",")}]`; if (typeof value === "object") { const entries = Object.entries(value as Record) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}:${stableSerialize(v)}`); return `{${entries.join(",")}}`; } return String(value); }; const isSameValue = (prev: any, next: any): boolean => stableSerialize(prev) === stableSerialize(next); const compactObjectValue = (value: Record): string => { const preferredKeys = ["name", "label", "title", "code", "id", "value"]; for (const key of preferredKeys) { const text = String(value[key] ?? "").trim(); if (text) return text; } const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== ""); if (entries.length === 0) return "未填写"; const rendered = entries .slice(0, 3) .map(([k, v]) => `${toReadableFieldLabel(k)}=${String(v)}`) .join(","); return entries.length > 3 ? `${rendered} 等${entries.length}项` : rendered; }; const toReadableFieldLabel = (key: string): string => { const normalized = String(key || "").trim(); if (!normalized) return "字段"; if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized]; return `字段(${normalized})`; }; const toReadableValue = (key: string, value: any): string => { if (value === null || value === undefined || value === "") return "未填写"; if (typeof value === "boolean") { if (key === "is_active") return value ? "启用" : "停用"; return value ? "是" : "否"; } if (typeof value === "number") return String(value); if (typeof value === "string") { const text = value.trim(); if (!text) return "未填写"; if (key.toLowerCase().includes("status")) { return getDictLabel(statusDict, text) || text; } return text; } if (Array.isArray(value)) { if (value.length === 0) return "未填写"; const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item)).join("、"); return value.length > 3 ? `${rendered} 等${value.length}项` : rendered; } if (typeof value === "object") { return compactObjectValue(value as Record); } return String(value); }; const buildDiffText = (before?: Record, after?: Record) => { if (!before && !after) return []; const lines: string[] = []; const prevObj = before || {}; const nextObj = after || {}; Object.keys({ ...prevObj, ...nextObj }).forEach((key) => { const prev = prevObj[key]; const next = nextObj[key]; if (isSameValue(prev, next)) return; const label = toReadableFieldLabel(key); const prevText = toReadableValue(key, prev); const nextText = toReadableValue(key, next); lines.push(`${label}:${prevText} -> ${nextText}`); }); return lines; }; export const normalizeAuditEvent = (raw: any, userMap: Record): AuditEvent => { const dict = auditDict[raw.action] || { label: raw.action ? `${TEXT.audit.eventFallback}(${raw.action})` : TEXT.audit.eventFallback, actionText: raw.action ? `执行了${raw.action}` : TEXT.audit.actionFallback, targetLabel: toReadableEntityType(raw.entity_type), }; let detailObj: any = {}; if (raw.detail) { try { detailObj = JSON.parse(raw.detail); } catch { detailObj = { description: raw.detail }; } } const before = detailObj.before; const after = detailObj.after; const fallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : ""; let diffText = detailObj.diffText || buildDiffText(before, after); if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) { diffText = fallbackDescription .split(/[;;\n]/) .map((item: string) => item.trim()) .filter(Boolean); } const readableDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : []) .flatMap((line) => splitAuditDetailSegments(String(line))) .map((line) => normalizeLegacyDetailLine(line)) .filter(Boolean); return { eventType: raw.action, eventLabel: dict.label, actorId: raw.operator_id, actorName: userMap[raw.operator_id] || raw.operator_id, actorRole: raw.operator_role, actorRoleLabel: getDictLabel(roleDict, raw.operator_role), targetType: raw.entity_type || "", targetTypeLabel: dict.targetLabel, targetId: raw.entity_id || "", targetName: detailObj.targetName || raw.entity_id || "", actionText: dict.actionText, before, after, diffText: readableDiffText, reason: detailObj.reason, result: detailObj.result || "SUCCESS", resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess, timestamp: raw.created_at, }; }; export { auditDict }; // Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage. export const logAudit = async (eventType: string, payload: Record) => { void eventType; void payload; return Promise.resolve(); };