301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
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();
|
||
};
|