Files
ctms/frontend/src/audit/index.ts
T
2026-05-12 10:16:52 +08:00

301 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, any>;
after?: Record<string, any>;
diffText?: string[];
reason?: string;
result: "SUCCESS" | "FAIL";
resultLabel: string;
timestamp: string;
}
const entityTypeLabelMap: Record<string, string> = {
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<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 "";
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<string, string> = {
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<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]) => `${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<string, any>);
}
return String(value);
};
const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>) => {
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<string, string>): 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<string, any>) => {
void eventType;
void payload;
return Promise.resolve();
};