774 lines
34 KiB
TypeScript
774 lines
34 KiB
TypeScript
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<string, any>;
|
||
after?: Record<string, any>;
|
||
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<string, string> = {
|
||
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<string, { labelPrefix: string; actionPrefix: string }> = {
|
||
CREATE: { labelPrefix: "新增", actionPrefix: "新增了" },
|
||
UPDATE: { labelPrefix: "更新", actionPrefix: "更新了" },
|
||
DELETE: { labelPrefix: "删除", actionPrefix: "删除了" },
|
||
};
|
||
|
||
const auditActionTargetMap: Record<string, string> = {
|
||
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<string, { label: string; actionText: string; targetLabel: string }> = {
|
||
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<string, { label: string; actionText: string; targetLabel: string }> = {
|
||
...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<string, string> = {
|
||
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<string, string> = {
|
||
药品运输已创建: "创建药品流向(历史日志未记录对象信息)",
|
||
药品运输已更新: "更新药品流向(历史日志未记录对象信息)",
|
||
药品运输已删除: "删除药品流向(历史日志未记录对象信息)",
|
||
设备已创建: "创建设备(历史日志未记录设备名称)",
|
||
设备已更新: "更新设备(历史日志未记录设备名称)",
|
||
设备已删除: "删除设备(历史日志未记录设备名称)",
|
||
合同费用已创建: "创建合同费用(历史日志未记录对象信息)",
|
||
合同费用已更新: "更新合同费用(历史日志未记录对象信息)",
|
||
合同费用已删除: "删除合同费用(历史日志未记录对象信息)",
|
||
合同费用分期已创建: "创建合同费用分期(历史日志未记录对象信息)",
|
||
合同费用分期已更新: "更新合同费用分期(历史日志未记录对象信息)",
|
||
合同费用分期已删除: "删除合同费用分期(历史日志未记录对象信息)",
|
||
立项记录已创建: "创建立项记录(历史日志未记录对象信息)",
|
||
立项记录已更新: "更新立项记录(历史日志未记录对象信息)",
|
||
立项记录已删除: "删除立项记录(历史日志未记录对象信息)",
|
||
伦理记录已创建: "创建伦理记录(历史日志未记录对象信息)",
|
||
伦理记录已更新: "更新伦理记录(历史日志未记录对象信息)",
|
||
伦理记录已删除: "删除伦理记录(历史日志未记录对象信息)",
|
||
启动会记录已创建: "创建启动会记录(历史日志未记录对象信息)",
|
||
启动会记录已更新: "更新启动会记录(历史日志未记录对象信息)",
|
||
培训授权人员已创建: "创建培训授权人员(历史日志未记录人员姓名)",
|
||
培训授权人员已更新: "更新培训授权人员(历史日志未记录人员姓名)",
|
||
培训授权人员已删除: "删除培训授权人员(历史日志未记录人员姓名)",
|
||
病史记录已创建: "创建病史记录(历史日志未记录参与者)",
|
||
病史记录已更新: "更新病史记录(历史日志未记录参与者)",
|
||
病史记录已删除: "删除病史记录(历史日志未记录参与者)",
|
||
访视已更新: "更新访视(历史日志未记录访视号)",
|
||
};
|
||
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, string>): 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<string, string> = {
|
||
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<string, string> = {
|
||
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<string, string> = {
|
||
DRAFT: "草稿",
|
||
SUBMITTED: "已提交",
|
||
APPROVED: "已批准",
|
||
EFFECTIVE: "已生效",
|
||
SUPERSEDED: "已被替代",
|
||
ARCHIVED: "已归档",
|
||
WITHDRAWN: "已撤回",
|
||
};
|
||
|
||
const documentStatusLabelMap: Record<string, string> = {
|
||
ACTIVE: "使用中",
|
||
ARCHIVED: "已归档",
|
||
};
|
||
|
||
const documentScopeTypeLabelMap: Record<string, string> = {
|
||
GLOBAL: "项目级文档",
|
||
SITE: "中心级文档",
|
||
DERIVED: "派生文档",
|
||
};
|
||
|
||
const distributionTargetTypeLabelMap: Record<string, string> = {
|
||
SITE: "中心",
|
||
ROLE: "角色",
|
||
USER: "指定人员",
|
||
};
|
||
|
||
const acknowledgementTypeLabelMap: Record<string, string> = {
|
||
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<string, any>)
|
||
.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, any>): 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, string>): 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<string, any>);
|
||
}
|
||
return String(value);
|
||
};
|
||
|
||
const buildDiffText = (
|
||
before: Record<string, any> | undefined,
|
||
after: Record<string, any> | undefined,
|
||
userMap: Record<string, string>,
|
||
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<string, string>): 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<string, any>) => {
|
||
void eventType;
|
||
void payload;
|
||
return Promise.resolve();
|
||
};
|