import { auditDict as localizedAuditDict } from "./auditDict"; import { roleDict, getDictLabel, statusDict } from "../dictionaries"; import { TEXT } from "../locales"; export interface AuditEvent { eventType: string; eventLabel: string; actorId: string; actorName: string; actorAccount?: 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; clientIp?: string; ipLocation?: string; ipCountry?: string; ipProvince?: string; ipCity?: string; ipIsp?: string; clientType?: string; clientVersion?: string; clientPlatform?: string; buildChannel?: string; buildCommit?: string; userAgent?: string; clientSourceLabel?: string; } const entityTypeLabelMap: Record = { study_setup_config: "立项配置", study: "项目", subject: "参与者", visit: "访视", ae: "不良事件", contract_fee: "合同费用条目", contract_fee_payment: "合同费用回款", drug_shipment: "药品流向", startup_feasibility: "立项记录", startup_ethics: "伦理记录", startup_kickoff: "启动会", training_authorization: "培训授权", precaution: "注意事项", subject_history: "病史记录", faq_category: "医学咨询分类", faq_item: "医学咨询问题", faq_reply: "医学咨询回复", DOCUMENT: "文档", DOCUMENT_VERSION: "文档版本", DISTRIBUTION: "文档分发", ACKNOWLEDGEMENT: "文档签收", ETMF_NODE: "eTMF 目录", }; const auditActionVerbMap: Record = { CREATE: { labelPrefix: "新增", actionPrefix: "新增了" }, UPDATE: { labelPrefix: "更新", actionPrefix: "更新了" }, DELETE: { labelPrefix: "删除", actionPrefix: "删除了" }, }; const auditActionTargetMap: Record = { FAQ_CATEGORY: "医学咨询分类", FAQ_ITEM: "医学咨询问题", FAQ_REPLY: "医学咨询回复", PRECAUTION: "注意事项", STARTUP_FEASIBILITY: "立项记录", STARTUP_ETHICS: "伦理记录", KICKOFF_MEETING: "启动会", TRAINING_AUTH: "培训授权", SITE: "中心", SUBJECT: "参与者", VISIT: "访视", AE: "不良事件", SUBJECT_PD: "PD 记录", SUBJECT_HISTORY: "病史记录", DRUG_SHIPMENT: "药品流向", MATERIAL_EQUIPMENT: "设备", MONITORING_VISIT_ISSUE: "监查访视问题", CONTRACT_FEE: "合同费用条目", CONTRACT_FEE_PAYMENT: "合同费用回款", ATTACHMENT: "附件", SETUP_CONFIG_VERSION: "立项配置版本", PROJECT_MILESTONE: "项目里程碑", DOCUMENT: "文档", DOCUMENT_VERSION: "文档版本", DISTRIBUTION: "文档分发", ACKNOWLEDGEMENT: "文档签收", }; const explicitAuditActionMap: Record = { LOCK_STUDY: { label: "锁定项目", actionText: "锁定了项目", targetLabel: "项目" }, UNLOCK_STUDY: { label: "解锁项目", actionText: "解锁了项目", targetLabel: "项目" }, ROLLBACK_SETUP_CONFIG: { label: "回滚立项配置", actionText: "回滚了立项配置", targetLabel: "立项配置" }, CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT: { label: "切换立项配置草稿分支", actionText: "切换了立项配置草稿分支", targetLabel: "立项配置" }, CLEAR_SETUP_CONFIG_DRAFT: { label: "清空立项配置草稿", actionText: "清空了立项配置草稿", targetLabel: "立项配置" }, REFILL_SETUP_CONFIG_DRAFT: { label: "回填立项配置草稿", actionText: "从当前发布版本回填了立项配置草稿", targetLabel: "立项配置" }, MERGE_SETUP_CONFIG_TO_MAIN: { label: "合并立项配置主版本", actionText: "合并了立项配置主版本", targetLabel: "立项配置" }, DELETE_SETUP_CONFIG_VERSION: { label: "删除立项配置版本", actionText: "删除了立项配置版本", targetLabel: "立项配置版本" }, CREATE_EARLY_TERMINATION: { label: "创建提前终止访视", actionText: "创建了提前终止访视", targetLabel: "访视" }, VISIT_STATUS_CHANGE: { label: "访视状态变更", actionText: "变更了访视状态", targetLabel: "访视" }, SUBJECT_STATUS_CHANGE: { label: "参与者状态变更", actionText: "变更了参与者状态", targetLabel: "参与者" }, AE_STATUS_CHANGE: { label: "AE 状态变更", actionText: "变更了 AE 状态", targetLabel: "不良事件" }, SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "变更了中心状态", targetLabel: "中心" }, UPLOAD_FILE: { label: "上传附件", actionText: "上传了附件", targetLabel: "附件" }, IMPORT_MONITORING_VISIT_ISSUE: { label: "导入监查访视问题", actionText: "导入了监查访视问题", targetLabel: "监查访视问题" }, DOCUMENT_CREATED: { label: "新增文档", actionText: "新增了文档", targetLabel: "文档" }, DOCUMENT_UPDATED: { label: "更新文档", actionText: "更新了文档", targetLabel: "文档" }, DOCUMENT_ARCHIVED: { label: "归档文档", actionText: "归档了文档", targetLabel: "文档" }, VERSION_CREATED: { label: "新增文档版本", actionText: "新增了文档版本", targetLabel: "文档版本" }, VERSION_DELETED: { label: "删除文档版本", actionText: "删除了文档版本", targetLabel: "文档版本" }, DISTRIBUTION_CREATED: { label: "发起文档分发", actionText: "发起了文档分发", targetLabel: "文档分发" }, ACK_CREATED: { label: "提交文档回执", actionText: "提交了文档回执", targetLabel: "文档签收" }, ETMF_NODE_CREATED: { label: "新增 eTMF 目录", actionText: "新增了 eTMF 目录", targetLabel: "eTMF 目录" }, ETMF_NODE_UPDATED: { label: "更新 eTMF 目录", actionText: "更新了 eTMF 目录", targetLabel: "eTMF 目录" }, }; export const auditDict: Record = { ...localizedAuditDict, ...explicitAuditActionMap, }; const buildReadableAuditDict = (action?: string, entityType?: string) => { const actionKey = String(action || "").trim(); if (explicitAuditActionMap[actionKey]) return explicitAuditActionMap[actionKey]; const match = actionKey.match(/^(CREATE|UPDATE|DELETE)_(.+)$/); if (match) { const verb = auditActionVerbMap[match[1]]; const target = auditActionTargetMap[match[2]] || toReadableEntityType(entityType); return { label: `${verb.labelPrefix}${target}`, actionText: `${verb.actionPrefix}${target}`, targetLabel: target, }; } return { label: actionKey ? TEXT.audit.eventFallback : TEXT.audit.eventFallback, actionText: TEXT.audit.actionFallback, targetLabel: toReadableEntityType(entityType), }; }; 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 ""; if (/^其余\s+\d+\s+项变更已省略$/.test(text)) return ""; if (/\/\s*ID[::]/.test(text) || /^ID[::]/.test(text)) return ""; const legacyFaqCategoryActionMatch = text.match(/^FAQ\s*分类\s+(.+?)\s+已(创建|更新|删除)$/i); if (legacyFaqCategoryActionMatch) { const categoryName = legacyFaqCategoryActionMatch[1].trim(); const verb = legacyFaqCategoryActionMatch[2]; const actionVerb = verb === "创建" ? "创建" : verb === "更新" ? "更新" : "删除"; return isUuidText(categoryName) ? `${actionVerb}分类(历史日志未记录分类名称)` : `${actionVerb}“${categoryName}”分类`; } if (/^FAQ\s*已创建$/i.test(text)) return "创建医学咨询问题"; if (/^FAQ\s*updated$/i.test(text) || /^FAQ\s*已更新$/i.test(text)) return "更新医学咨询问题"; if (/^FAQ\s*已删除$/i.test(text)) return "删除医学咨询问题"; if (/^FAQ\s*已回复$/i.test(text)) return "回复医学咨询问题"; if (/^FAQ\s*回复已删除$/i.test(text)) return "删除医学咨询回复"; const legacyDrugShipmentMatch = text.match(/^药品运输\s+(.+?)\s+已(创建|更新|删除)$/); if (legacyDrugShipmentMatch) { const name = legacyDrugShipmentMatch[1].trim(); const verb = legacyDrugShipmentMatch[2]; return isUuidText(name) ? `${verb}药品流向(历史日志未记录对象信息)` : `${verb}药品流向“${name}”`; } const legacyEquipmentMatch = text.match(/^设备\s+(.+?)\s+已(创建|更新|删除)$/); if (legacyEquipmentMatch) { const name = legacyEquipmentMatch[1].trim(); const verb = legacyEquipmentMatch[2]; return isUuidText(name) ? `${verb}设备(历史日志未记录设备名称)` : `${verb}设备“${name}”`; } const legacyPrecautionMatch = text.match(/^注意事项\s+(.+?)\s+已(创建|更新|删除)$/); if (legacyPrecautionMatch) { const name = legacyPrecautionMatch[1].trim(); const verb = legacyPrecautionMatch[2]; return isUuidText(name) ? `${verb}注意事项(历史日志未记录标题)` : `${verb}注意事项“${name}”`; } const legacyVisitMatch = text.match(/^访视\s+(.+?)\s+已(创建|更新|删除)$/); if (legacyVisitMatch) { const name = legacyVisitMatch[1].trim(); const verb = legacyVisitMatch[2]; return isUuidText(name) ? `${verb}访视(历史日志未记录访视号)` : `${verb}访视“${name}”`; } const legacyAeMatch = text.match(/^AE\s+(.+?)\s+已(创建|删除)$/i); if (legacyAeMatch) { const name = legacyAeMatch[1].trim(); const verb = legacyAeMatch[2]; return isUuidText(name) ? `${verb}AE(历史日志未记录 AE 术语)` : `${verb}AE“${name}”`; } const genericLegacyActionMap: Record = { 药品运输已创建: "创建药品流向(历史日志未记录对象信息)", 药品运输已更新: "更新药品流向(历史日志未记录对象信息)", 药品运输已删除: "删除药品流向(历史日志未记录对象信息)", 设备已创建: "创建设备(历史日志未记录设备名称)", 设备已更新: "更新设备(历史日志未记录设备名称)", 设备已删除: "删除设备(历史日志未记录设备名称)", 合同费用已创建: "创建合同费用(历史日志未记录对象信息)", 合同费用已更新: "更新合同费用(历史日志未记录对象信息)", 合同费用已删除: "删除合同费用(历史日志未记录对象信息)", 合同费用分期已创建: "创建合同费用分期(历史日志未记录对象信息)", 合同费用分期已更新: "更新合同费用分期(历史日志未记录对象信息)", 合同费用分期已删除: "删除合同费用分期(历史日志未记录对象信息)", 立项记录已创建: "创建立项记录(历史日志未记录对象信息)", 立项记录已更新: "更新立项记录(历史日志未记录对象信息)", 立项记录已删除: "删除立项记录(历史日志未记录对象信息)", 伦理记录已创建: "创建伦理记录(历史日志未记录对象信息)", 伦理记录已更新: "更新伦理记录(历史日志未记录对象信息)", 伦理记录已删除: "删除伦理记录(历史日志未记录对象信息)", 启动会记录已创建: "创建启动会记录(历史日志未记录对象信息)", 启动会记录已更新: "更新启动会记录(历史日志未记录对象信息)", 培训授权人员已创建: "创建培训授权人员(历史日志未记录人员姓名)", 培训授权人员已更新: "更新培训授权人员(历史日志未记录人员姓名)", 培训授权人员已删除: "删除培训授权人员(历史日志未记录人员姓名)", 病史记录已创建: "创建病史记录(历史日志未记录参与者)", 病史记录已更新: "更新病史记录(历史日志未记录参与者)", 病史记录已删除: "删除病史记录(历史日志未记录参与者)", 访视已更新: "更新访视(历史日志未记录访视号)", }; if (genericLegacyActionMap[text]) return genericLegacyActionMap[text]; const legacySubjectActionMatch = text.match(/^参与者\s+(.+?)\s+已(创建|更新|删除)$/); if (legacySubjectActionMatch) { const subjectNo = legacySubjectActionMatch[1].trim(); const verb = legacySubjectActionMatch[2]; return isUuidText(subjectNo) ? `${verb}参与者(历史日志未记录参与者编号)` : `${verb}参与者 ${subjectNo}`; } 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 removeUuidOnlyIdentifier(text); }; const uuidTextPattern = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; const removeUuidOnlyIdentifier = (text: string): string => { return text.replace(new RegExp(`\\s+${uuidTextPattern}\\s+(?=已(?:创建|更新|删除))`, "g"), ""); }; const isUuidText = (value: unknown): boolean => { return new RegExp(`^${uuidTextPattern}$`).test(String(value || "").trim()); }; const replaceUuidReferences = (text: string, userMap: Record): string => { return String(text || "").replace(new RegExp(uuidTextPattern, "g"), (uuid) => userMap[uuid] || "未识别对象"); }; const extractLegacySubjectTargetName = (description: string): string => { const match = String(description || "").trim().match(/^参与者\s+(.+?)\s+已(?:创建|更新|删除)$/); if (!match) return ""; const subjectNo = match[1].trim(); return isUuidText(subjectNo) ? "" : subjectNo; }; const extractLegacyFaqCategoryTargetName = (description: string): string => { const match = String(description || "").trim().match(/^FAQ\s*分类\s+(.+?)\s+已(?:创建|更新|删除)$/i); if (!match) return ""; const categoryName = match[1].trim(); return isUuidText(categoryName) ? "" : categoryName; }; const enrichStructuredDescription = (action: string, description: string, targetName: string): string => { const desc = String(description || "").trim(); const target = String(targetName || "").trim(); if (!desc || !target || desc.includes("“")) return desc; if (action === "CREATE_FAQ_ITEM" && desc === "创建医学咨询问题") return `创建医学咨询问题“${target}”`; if (action === "UPDATE_FAQ_ITEM" && desc === "更新医学咨询问题") return `更新医学咨询问题“${target}”`; if (action === "DELETE_FAQ_ITEM" && desc === "删除医学咨询问题") return `删除医学咨询问题“${target}”`; if (action === "CREATE_FAQ_REPLY" && desc === "回复医学咨询问题") return `回复医学咨询问题“${target}”`; if (action === "DELETE_FAQ_REPLY" && desc === "删除医学咨询回复") return `删除医学咨询问题“${target}”的回复`; const genericStructuredActionMap: Record = { CREATE_DRUG_SHIPMENT: "创建药品流向", UPDATE_DRUG_SHIPMENT: "更新药品流向", DELETE_DRUG_SHIPMENT: "删除药品流向", CREATE_MATERIAL_EQUIPMENT: "创建设备", UPDATE_MATERIAL_EQUIPMENT: "更新设备", DELETE_MATERIAL_EQUIPMENT: "删除设备", CREATE_PRECAUTION: "创建注意事项", UPDATE_PRECAUTION: "更新注意事项", DELETE_PRECAUTION: "删除注意事项", CREATE_VISIT: "创建访视", UPDATE_VISIT: "更新访视", DELETE_VISIT: "删除访视", CREATE_AE: "创建AE", UPDATE_AE: "更新AE", DELETE_AE: "删除AE", CREATE_CONTRACT_FEE: "创建合同费用", UPDATE_CONTRACT_FEE: "更新合同费用", DELETE_CONTRACT_FEE: "删除合同费用", CREATE_CONTRACT_FEE_PAYMENT: "创建合同费用分期", UPDATE_CONTRACT_FEE_PAYMENT: "更新合同费用分期", DELETE_CONTRACT_FEE_PAYMENT: "删除合同费用分期", CREATE_STARTUP_FEASIBILITY: "创建立项记录", UPDATE_STARTUP_FEASIBILITY: "更新立项记录", DELETE_STARTUP_FEASIBILITY: "删除立项记录", CREATE_STARTUP_ETHICS: "创建伦理记录", UPDATE_STARTUP_ETHICS: "更新伦理记录", DELETE_STARTUP_ETHICS: "删除伦理记录", CREATE_KICKOFF_MEETING: "创建启动会记录", UPDATE_KICKOFF_MEETING: "更新启动会记录", CREATE_TRAINING_AUTH: "创建培训授权人员", UPDATE_TRAINING_AUTH: "更新培训授权人员", DELETE_TRAINING_AUTH: "删除培训授权人员", CREATE_SUBJECT_HISTORY: "创建病史记录", UPDATE_SUBJECT_HISTORY: "更新病史记录", DELETE_SUBJECT_HISTORY: "删除病史记录", }; const structuredPrefix = genericStructuredActionMap[action]; if (structuredPrefix && desc === structuredPrefix) return `${structuredPrefix}“${target}”`; return desc; }; const isSetupModuleSummaryLine = (line: string): boolean => /^变更模块[::]/.test(String(line || "").trim()); const buildActionText = ( dictActionText: string, diffLines: string[], summaryText = "", preferDictAction = false ): string => { if (summaryText) return summaryText; if (preferDictAction) return dictActionText; if (diffLines.length > 0) return diffLines[0]; return dictActionText; }; 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 = { id: "记录标识", trial_id: "所属项目", study_id: "所属项目", document_id: "所属文档", version_id: "文档版本", distribution_id: "分发记录", target_id: "分发对象", site_id: "中心", etmf_node_id: "eTMF 目录", current_effective_version_id: "当前生效版本", parent_version_id: "上一版本", derived_from_version_id: "来源版本", owner_id: "负责人", issue_no: "问题编号", source: "问题来源", monitor_type: "监查类型", monitor_item: "监查项", category: "问题分类", recommendation: "建议措施", subject_name: "受试者", subject_code: "受试者缩写号", subject_no: "参与者编号", 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: "电话", doc_no: "文档编号", doc_type: "文档类型", title: "文档标题", scope_type: "文档范围", version_no: "版本号", file_hash: "文件校验值", file_uri: "文件位置", file_size: "文件大小", mime_type: "文件类型", change_summary: "变更说明", effective_at: "生效时间", superseded_at: "替代时间", submitted_at: "提交时间", approved_at: "批准时间", withdrawn_at: "撤回时间", created_at: "创建时间", created_by: "创建人", target_type: "分发范围", ack_type: "回执类型", acked_at: "回执时间", note: "备注", plan_start_date: "计划开始日期", plan_end_date: "计划结束日期", screening_date: "筛选日期", consent_date: "知情同意日期", enrollment_date: "入组日期", baseline_date: "基线日期", completion_date: "完成日期", actual_medication_count: "实际给药次数", drop_reason: "脱落原因", }; const documentVersionStatusLabelMap: Record = { DRAFT: "草稿", SUBMITTED: "已提交", APPROVED: "已批准", EFFECTIVE: "已生效", SUPERSEDED: "已被替代", ARCHIVED: "已归档", WITHDRAWN: "已撤回", }; const documentStatusLabelMap: Record = { ACTIVE: "使用中", ARCHIVED: "已归档", }; const documentScopeTypeLabelMap: Record = { GLOBAL: "项目级文档", SITE: "中心级文档", DERIVED: "派生文档", }; const distributionTargetTypeLabelMap: Record = { SITE: "中心", ROLE: "角色", USER: "指定人员", }; const acknowledgementTypeLabelMap: Record = { RECEIVED: "已接收", READ: "已阅读", TRAINED: "已培训", }; const normalizeEnumToken = (text: string): string => { const trimmed = String(text || "").trim(); const parts = trimmed.split("."); return parts[parts.length - 1] || trimmed; }; 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]) => { const valStr = typeof v === "object" ? JSON.stringify(v) : String(v); return `${toReadableFieldLabel(k)}=${valStr}`; }) .join(","); return entries.length > 3 ? `${rendered} 等${entries.length}项` : rendered; }; const toReadableFieldLabel = (key: string, entityType = ""): string => { const normalized = String(key || "").trim(); if (!normalized) return "字段"; if (normalized === "status" && entityType === "DOCUMENT_VERSION") return "版本状态"; if (normalized === "status" && entityType === "DOCUMENT") return "文档状态"; if (normalized === "due_at" && (entityType === "DISTRIBUTION" || entityType === "ACKNOWLEDGEMENT")) return "截止日期"; if (auditFieldLabelMap[normalized]) return auditFieldLabelMap[normalized]; return `字段(${normalized})`; }; const readableUuidPlaceholder = (key: string): string => { const normalized = String(key || "").trim(); if (normalized === "id") return "已生成"; if (normalized === "trial_id" || normalized === "study_id") return "已关联项目"; if (normalized === "document_id") return "已关联文档"; if (normalized === "version_id" || normalized.endsWith("_version_id")) return "已关联版本"; if (normalized === "distribution_id") return "已关联分发记录"; if (normalized === "site_id") return "已关联中心"; if (normalized === "owner_id" || normalized === "created_by" || normalized.endsWith("_user_id")) return "已关联人员"; if (normalized.endsWith("_id")) return "已关联对象"; return "系统记录"; }; const toReadableEnumValue = (key: string, text: string): string => { const normalizedText = normalizeEnumToken(text); if (key === "status") { return documentVersionStatusLabelMap[normalizedText] || documentStatusLabelMap[normalizedText] || getDictLabel(statusDict, normalizedText) || text; } if (key === "scope_type") return documentScopeTypeLabelMap[normalizedText] || text; if (key === "target_type") return distributionTargetTypeLabelMap[normalizedText] || text; if (key === "ack_type") return acknowledgementTypeLabelMap[normalizedText] || text; if (key.toLowerCase().includes("status")) { return documentVersionStatusLabelMap[normalizedText] || documentStatusLabelMap[normalizedText] || getDictLabel(statusDict, normalizedText) || text; } return text; }; const toReadableValue = (key: string, value: any, userMap: Record): 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 === "file_hash") return "已记录校验值"; if (isUuidText(text)) return userMap[text] || readableUuidPlaceholder(key); if (key === "version_no") return text.startsWith("V") ? text : `V${text}`; return toReadableEnumValue(key, text); } if (Array.isArray(value)) { if (value.length === 0) return "未填写"; const rendered = value.slice(0, 3).map((item) => toReadableValue(key, item, userMap)).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 | undefined, after: Record | undefined, userMap: Record, entityType = "" ) => { 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, entityType); const prevText = toReadableValue(key, prev, userMap); const nextText = toReadableValue(key, next, userMap); lines.push(`${label}:${prevText} -> ${nextText}`); }); return lines; }; const fallbackIpLocation = (ip: string | null | undefined): string => { if (!ip) return ""; if (ip === "127.0.0.1" || ip === "::1") return "本机访问"; if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址"; return ""; }; export const formatAuditIpLocation = (raw: { ip_country?: string | null; ip_province?: string | null; ip_city?: string | null; ip_isp?: string | null; ip_location?: string | null; client_ip?: string | null; clientIp?: string | null; }): string => { const parts = [ raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "", raw.ip_province, raw.ip_city, raw.ip_isp, ].filter(Boolean); if (parts.length) return parts.join(" / "); return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || ""; }; const isAutomationUserAgent = (userAgent = ""): boolean => /curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent); const isBrowserUserAgent = (userAgent = ""): boolean => /mozilla|chrome|safari|firefox|edg\//i.test(userAgent); export const resolveAuditClientSourceLabel = (raw: { client_type?: string | null; client_platform?: string | null; user_agent?: string | null; clientType?: string | null; clientPlatform?: string | null; userAgent?: string | null; }): string => { const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase(); const platform = String(raw.client_platform || raw.clientPlatform || "").trim(); const userAgent = String(raw.user_agent || raw.userAgent || "").trim(); if (clientType === "web") return "网页端"; if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端"; if (clientType) return `未知客户端 / ${clientType}`; if (isAutomationUserAgent(userAgent)) return "脚本/命令行"; if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)"; return "未知来源"; }; export const normalizeAuditEvent = (raw: any, userMap: Record): AuditEvent => { const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, 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 detailTargetName = String(detailObj.targetName || "").trim(); const rawFallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : ""; const fallbackDescription = enrichStructuredDescription(raw.action, rawFallbackDescription, isUuidText(detailTargetName) ? "" : detailTargetName); let diffText = detailObj.diffText || buildDiffText(before, after, userMap, raw.entity_type || ""); if ((!Array.isArray(diffText) || diffText.length === 0) && fallbackDescription) { diffText = fallbackDescription .split(/[;;\n]/) .map((item: string) => item.trim()) .filter(Boolean); } const normalizedDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : []) .flatMap((line) => splitAuditDetailSegments(String(line))) .map((line) => normalizeLegacyDetailLine(line)) .map((line) => replaceUuidReferences(line, userMap)) .filter(Boolean); const setupModuleSummary = normalizedDiffText.find((line) => isSetupModuleSummaryLine(line)) || ""; const readableDiffText = normalizedDiffText.filter((line) => !isSetupModuleSummaryLine(line)); const legacySubjectTargetName = extractLegacySubjectTargetName(fallbackDescription); const legacyFaqCategoryTargetName = extractLegacyFaqCategoryTargetName(fallbackDescription); const targetName = isUuidText(detailTargetName) ? userMap[detailTargetName] || "" : detailObj.targetName || legacySubjectTargetName || legacyFaqCategoryTargetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""); const preferDictAction = Boolean(explicitAuditActionMap[raw.action] && fallbackDescription); const hasStructuredDetail = typeof raw.detail === "string" && raw.detail.trim().startsWith("{"); const actionText = raw.action === "DELETE_SUBJECT" && hasStructuredDetail && fallbackDescription ? fallbackDescription : buildActionText( dict.actionText, readableDiffText, setupModuleSummary, preferDictAction ); return { eventType: raw.action, eventLabel: dict.label, actorId: raw.operator_id, actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id, actorAccount: raw.operator_email || 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, 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, clientIp: raw.client_ip || "", ipLocation: formatAuditIpLocation(raw), ipCountry: raw.ip_country || "", ipProvince: raw.ip_province || "", ipCity: raw.ip_city || "", ipIsp: raw.ip_isp || "", clientType: raw.client_type || "", clientVersion: raw.client_version || "", clientPlatform: raw.client_platform || "", buildChannel: raw.build_channel || "", buildCommit: raw.build_commit || "", userAgent: raw.user_agent || "", clientSourceLabel: resolveAuditClientSourceLabel(raw), }; }; // 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(); };