完善审计日志展示与导出权限
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
import { apiDelete, apiGet, apiPost } from "./axios";
|
||||
import { apiGet, apiPost } from "./axios";
|
||||
import type { ApiListResponse } from "../types/api";
|
||||
|
||||
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
|
||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
|
||||
|
||||
export const deleteAuditLog = (studyId: string, logId: string) =>
|
||||
apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`);
|
||||
|
||||
export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
|
||||
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
|
||||
|
||||
@@ -16,10 +16,34 @@ describe("normalizeAuditEvent", () => {
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.diffText).toEqual(["FAQ 分类已删除"]);
|
||||
expect(event.diffText).toEqual(["删除分类(历史日志未记录分类名称)"]);
|
||||
expect(event.targetName).toBe("");
|
||||
});
|
||||
|
||||
it("renders legacy FAQ category actions as business descriptions", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_FAQ_CATEGORY",
|
||||
detail: JSON.stringify({
|
||||
targetName: "入排标准",
|
||||
description: "FAQ 分类 入排标准 已创建",
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "faq_category",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T11:16:04Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("新增医学咨询分类");
|
||||
expect(event.targetTypeLabel).toBe("医学咨询分类");
|
||||
expect(event.targetName).toBe("入排标准");
|
||||
expect(event.diffText).toEqual(["创建“入排标准”分类"]);
|
||||
expect(event.actionText).toBe("创建“入排标准”分类");
|
||||
});
|
||||
|
||||
it("keeps readable shipment identifiers but hides UUID-only shipment identifiers", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
@@ -34,7 +58,436 @@ describe("normalizeAuditEvent", () => {
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.diffText).toEqual(["药品运输已创建"]);
|
||||
expect(event.diffText).toEqual(["创建药品流向(历史日志未记录对象信息)"]);
|
||||
expect(event.targetName).toBe("");
|
||||
});
|
||||
|
||||
it("moves legacy subject numbers into the target column", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_SUBJECT",
|
||||
detail: "参与者 S001 已创建",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "subject",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-08T15:44:48Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("新增参与者");
|
||||
expect(event.targetTypeLabel).toBe("参与者");
|
||||
expect(event.targetName).toBe("S001");
|
||||
expect(event.diffText).toEqual(["创建参与者 S001"]);
|
||||
expect(event.actionText).toBe("创建参与者 S001");
|
||||
});
|
||||
|
||||
|
||||
it("maps legacy project member target UUIDs to user display names", () => {
|
||||
const userId = "169ede81-f2cb-4b44-84c5-c11111111111";
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "PROJECT_MEMBER_UPDATED",
|
||||
detail: JSON.stringify({
|
||||
targetName: userId,
|
||||
before: { role_in_study: "CRA" },
|
||||
after: { role_in_study: "PM" },
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "study_member",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{ [userId]: "张三" }
|
||||
);
|
||||
|
||||
expect(event.targetName).toBe("张三");
|
||||
expect(event.targetName).not.toContain(userId);
|
||||
});
|
||||
|
||||
it("maps user UUIDs inside audit detail lines to display names", () => {
|
||||
const userId = "94dccdc4-5592-4e59-b743-c33333333333";
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "SITE_UPDATED",
|
||||
detail: JSON.stringify({
|
||||
targetName: "重庆市璧山区人民医院",
|
||||
before: { contact: userId },
|
||||
after: { contact: "汤成泳" },
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "site",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{ [userId]: "李四" }
|
||||
);
|
||||
|
||||
expect(event.diffText).toEqual(["负责人:李四 -> 汤成泳"]);
|
||||
});
|
||||
|
||||
it("does not expose unmapped UUID-only target names", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "PROJECT_MEMBER_ADDED",
|
||||
detail: JSON.stringify({
|
||||
targetName: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
after: { role_in_study: "PM" },
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "study_member",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.targetName).toBe("");
|
||||
});
|
||||
|
||||
it("translates unknown CRUD action names into readable Chinese labels", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_FAQ_ITEM",
|
||||
detail: JSON.stringify({ targetName: "药物保存咨询" }),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "faq_item",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("新增医学咨询问题");
|
||||
expect(event.actionText).toBe("新增了医学咨询问题");
|
||||
expect(event.eventLabel).not.toContain("CREATE_FAQ_ITEM");
|
||||
expect(event.actionText).not.toContain("CREATE_FAQ_ITEM");
|
||||
});
|
||||
|
||||
it("uses FAQ question titles in generic structured FAQ descriptions", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_FAQ_ITEM",
|
||||
detail: JSON.stringify({
|
||||
targetName: "试验期间是否允许接种疫苗?",
|
||||
description: "创建医学咨询问题",
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "faq_item",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-09T08:55:44Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.targetName).toBe("试验期间是否允许接种疫苗?");
|
||||
expect(event.diffText).toEqual(["创建医学咨询问题“试验期间是否允许接种疫苗?”"]);
|
||||
expect(event.actionText).toBe("创建医学咨询问题“试验期间是否允许接种疫苗?”");
|
||||
});
|
||||
|
||||
it("keeps action text focused on the business operation when target names exist", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_FAQ_ITEM",
|
||||
detail: JSON.stringify({ targetName: "药物保存咨询" }),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "faq_item",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("新增医学咨询问题");
|
||||
expect(event.actionText).toBe("新增了医学咨询问题");
|
||||
expect(event.targetName).toBe("药物保存咨询");
|
||||
});
|
||||
|
||||
it("uses changed fields as action text when the target name duplicates the operation", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "UPDATE_FAQ_CATEGORY",
|
||||
detail: JSON.stringify({
|
||||
targetName: "医学咨询分类",
|
||||
before: { name: "旧分类" },
|
||||
after: { name: "新分类" },
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "faq_category",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("更新医学咨询分类");
|
||||
expect(event.actionText).toBe("名称:旧分类 -> 新分类");
|
||||
});
|
||||
|
||||
it("keeps setup module summary out of change detail lines", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "SAVE_STUDY_SETUP_CONFIG",
|
||||
detail: JSON.stringify({
|
||||
description:
|
||||
"变更模块:项目里程碑; 变更明细:项目里程碑 / 参研中心调研+方案讨论会 / 耗时(天):30 -> 28;项目里程碑 / 参研中心调研+方案讨论会 / 计划日期:2026-06-01 -> 2026-06-03;项目里程碑 / 参研中心调研+方案讨论会 / 开始日期:2026-06-01 -> 2026-06-03",
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "study_setup_config",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-28T09:15:23Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.actionText).toBe("变更模块:项目里程碑");
|
||||
expect(event.diffText).toEqual([
|
||||
"项目里程碑 / 参研中心调研+方案讨论会 / 耗时(天):30 -> 28",
|
||||
"项目里程碑 / 参研中心调研+方案讨论会 / 计划日期:2026-06-01 -> 2026-06-03",
|
||||
"项目里程碑 / 参研中心调研+方案讨论会 / 开始日期:2026-06-01 -> 2026-06-03",
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides legacy setup technical ID lines and omission placeholders", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "UPDATE_SETUP_CONFIG",
|
||||
detail:
|
||||
"变更模块:项目里程碑; 变更明细:项目里程碑 / 参研中心调研+方案讨论会 / ID:未填写 -> 1778477970865_08roj;项目里程碑 / 参研中心调研+方案讨论会 / 名称:未填写 -> 参研中心调研+方案讨论会;其余 115 项变更已省略",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "study_setup_config",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-28T09:15:23Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.diffText).toEqual([
|
||||
"项目里程碑 / 参研中心调研+方案讨论会 / 名称:未填写 -> 参研中心调研+方案讨论会",
|
||||
]);
|
||||
expect((event.diffText || []).join(" ")).not.toContain("ID");
|
||||
expect((event.diffText || []).join(" ")).not.toContain("已省略");
|
||||
});
|
||||
|
||||
it("renders document version audit details in business language", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "VERSION_CREATED",
|
||||
detail: JSON.stringify({
|
||||
before: null,
|
||||
after: {
|
||||
id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
document_id: "169ede81-f2cb-4b44-84c5-c11111111111",
|
||||
version_no: "1.0",
|
||||
status: "EFFECTIVE",
|
||||
file_hash: "95ac0061c81793d533906155921871182076c2dd5bbdd1be0b0e09ebdff17894",
|
||||
},
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "DOCUMENT_VERSION",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:42:21Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("新增文档版本");
|
||||
expect(event.targetTypeLabel).toBe("文档版本");
|
||||
expect(event.actionText).toBe("记录标识:未填写 -> 已生成");
|
||||
expect(event.diffText).toEqual([
|
||||
"记录标识:未填写 -> 已生成",
|
||||
"所属文档:未填写 -> 已关联文档",
|
||||
"版本号:未填写 -> V1.0",
|
||||
"版本状态:未填写 -> 已生效",
|
||||
"文件校验值:未填写 -> 已记录校验值",
|
||||
]);
|
||||
const detailText = (event.diffText || []).join(" ");
|
||||
expect(detailText).not.toContain("字段(id)");
|
||||
expect(detailText).not.toContain("未识别对象");
|
||||
expect(detailText).not.toContain("DocumentVersionStatus");
|
||||
expect(detailText).not.toContain("95ac0061");
|
||||
});
|
||||
|
||||
it("renders enum class-prefixed document version statuses in business language", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "VERSION_CREATED",
|
||||
detail: JSON.stringify({
|
||||
before: null,
|
||||
after: {
|
||||
status: "DocumentVersionStatus.EFFECTIVE",
|
||||
},
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "DOCUMENT_VERSION",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:42:21Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.diffText).toEqual(["版本状态:未填写 -> 已生效"]);
|
||||
expect((event.diffText || []).join(" ")).not.toContain("DocumentVersionStatus");
|
||||
});
|
||||
|
||||
it("renders structured subject delete audits with subject business context", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "DELETE_SUBJECT",
|
||||
detail: JSON.stringify({
|
||||
targetName: "SUBJ-001",
|
||||
description: "删除参与者 SUBJ-001",
|
||||
before: {
|
||||
subject_no: "SUBJ-001",
|
||||
site_name: "北京协和医院",
|
||||
status: "SCREENING",
|
||||
consent_date: "2026-05-01",
|
||||
baseline_date: null,
|
||||
},
|
||||
after: null,
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "subject",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:42:21Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.eventLabel).toBe("删除参与者");
|
||||
expect(event.targetName).toBe("SUBJ-001");
|
||||
expect(event.actionText).toBe("删除参与者 SUBJ-001");
|
||||
expect(event.diffText).toContain("参与者编号:SUBJ-001 -> 未填写");
|
||||
expect(event.diffText).toContain("中心名称:北京协和医院 -> 未填写");
|
||||
expect(event.diffText).toContain("知情同意日期:2026-05-01 -> 未填写");
|
||||
expect((event.diffText || []).join(" ")).not.toContain("字段(subject_no)");
|
||||
});
|
||||
|
||||
it("explains legacy subject delete logs that only stored UUIDs", () => {
|
||||
const event = normalizeAuditEvent(
|
||||
{
|
||||
action: "DELETE_SUBJECT",
|
||||
detail: "参与者 307e9a37-530c-4894-8d19-f22222222222 已删除",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "subject",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:42:21Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(event.diffText).toEqual(["删除参与者(历史日志未记录参与者编号)"]);
|
||||
expect(event.actionText).toBe("删除参与者(历史日志未记录参与者编号)");
|
||||
});
|
||||
|
||||
it("renders legacy drug and equipment audit details with business context", () => {
|
||||
const shipmentEvent = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_DRUG_SHIPMENT",
|
||||
detail: JSON.stringify({
|
||||
targetName: "TRACK-001",
|
||||
description: "药品运输 TRACK-001 已创建",
|
||||
}),
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "drug_shipment",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:37:58Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
const equipmentEvent = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_MATERIAL_EQUIPMENT",
|
||||
detail: "设备 307e9a37-530c-4894-8d19-f22222222222 已创建",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "material_equipment",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:36:19Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(shipmentEvent.targetName).toBe("TRACK-001");
|
||||
expect(shipmentEvent.actionText).toBe("创建药品流向“TRACK-001”");
|
||||
expect(equipmentEvent.actionText).toBe("创建设备(历史日志未记录设备名称)");
|
||||
expect(equipmentEvent.targetName).toBe("");
|
||||
});
|
||||
|
||||
it("marks generic legacy fee and startup audit details as missing object context", () => {
|
||||
const feeEvent = normalizeAuditEvent(
|
||||
{
|
||||
action: "CREATE_CONTRACT_FEE",
|
||||
detail: "合同费用已创建",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "contract_fee",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:36:19Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
const startupEvent = normalizeAuditEvent(
|
||||
{
|
||||
action: "UPDATE_STARTUP_FEASIBILITY",
|
||||
detail: "立项记录已更新",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "startup_feasibility",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-05-26T09:33:41Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(feeEvent.actionText).toBe("创建合同费用(历史日志未记录对象信息)");
|
||||
expect(startupEvent.actionText).toBe("更新立项记录(历史日志未记录对象信息)");
|
||||
});
|
||||
|
||||
it("renders high-value non-CRUD setup and lock actions in business language", () => {
|
||||
const lockEvent = normalizeAuditEvent(
|
||||
{
|
||||
action: "LOCK_STUDY",
|
||||
detail: "项目已锁定:真实世界研究 (RWS-001)",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "ADMIN",
|
||||
entity_type: "study",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
const rollbackEvent = normalizeAuditEvent(
|
||||
{
|
||||
action: "ROLLBACK_SETUP_CONFIG",
|
||||
detail: "立项配置已回滚并替换当前发布为 v3,草稿分支已切换到对应分支基线",
|
||||
operator_id: "operator-1",
|
||||
operator_role: "PM",
|
||||
entity_type: "study_setup_config",
|
||||
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
|
||||
created_at: "2026-06-04T10:27:02Z",
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(lockEvent.eventLabel).toBe("锁定项目");
|
||||
expect(lockEvent.actionText).toBe("锁定了项目");
|
||||
expect(rollbackEvent.eventLabel).toBe("回滚立项配置");
|
||||
expect(rollbackEvent.actionText).toBe("回滚了立项配置");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditRow } from "./auditExportFormatter";
|
||||
import type { AuditEvent } from "..";
|
||||
|
||||
const baseEvent: AuditEvent = {
|
||||
eventType: "DOCUMENT_UPDATED",
|
||||
eventLabel: "更新文档",
|
||||
actorId: "operator-1",
|
||||
actorName: "张三",
|
||||
actorRole: "PM",
|
||||
actorRoleLabel: "项目经理",
|
||||
targetType: "DOCUMENT",
|
||||
targetTypeLabel: "文档",
|
||||
targetId: "doc-001",
|
||||
actionText: "更新了文档",
|
||||
result: "SUCCESS",
|
||||
resultLabel: "成功",
|
||||
timestamp: "2026-06-04T10:27:02Z",
|
||||
};
|
||||
|
||||
describe("formatAuditRow", () => {
|
||||
it("keeps target identifiers inside a complete Chinese parenthesis pair", () => {
|
||||
const row = formatAuditRow(baseEvent);
|
||||
|
||||
expect(row.description).toContain("对象:文档(doc-001)");
|
||||
expect(row.description).not.toContain("doc-001)");
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
|
||||
const descriptionParts: string[] = [];
|
||||
descriptionParts.push(`${event.actorName} ${event.actionText}`);
|
||||
if (event.targetTypeLabel || event.targetName || event.targetId) {
|
||||
const targetIdentifier = event.targetName || event.targetId;
|
||||
descriptionParts.push(
|
||||
`${TEXT.audit.objectLabel}${event.targetTypeLabel || ""}${
|
||||
event.targetName ? `(${event.targetName})` : `(${event.targetId}`})`
|
||||
targetIdentifier ? `(${targetIdentifier})` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (event.diffText?.length) {
|
||||
|
||||
+407
-25
@@ -1,4 +1,4 @@
|
||||
import { auditDict } from "./auditDict";
|
||||
import { auditDict as localizedAuditDict } from "./auditDict";
|
||||
import { roleDict, getDictLabel, statusDict } from "../dictionaries";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
@@ -45,6 +45,94 @@ const entityTypeLabelMap: Record<string, string> = {
|
||||
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> = {
|
||||
@@ -75,6 +163,86 @@ const normalizeSetupModuleText = (raw: string): string => {
|
||||
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();
|
||||
@@ -107,7 +275,7 @@ const normalizeLegacyDetailLine = (line: string): string => {
|
||||
const detailMatch = text.match(/^变更明细[::]\s*(.*)$/);
|
||||
if (detailMatch) {
|
||||
const detailText = detailMatch[1].trim();
|
||||
return `变更明细:${detailText || "无"}`;
|
||||
return detailText;
|
||||
}
|
||||
if (text === "立项配置已发布") return "立项配置已发布";
|
||||
if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划");
|
||||
@@ -124,6 +292,89 @@ 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 [];
|
||||
@@ -134,6 +385,19 @@ const splitAuditDetailSegments = (line: string): string[] => {
|
||||
};
|
||||
|
||||
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: "监查类型",
|
||||
@@ -142,6 +406,7 @@ const auditFieldLabelMap: Record<string, string> = {
|
||||
recommendation: "建议措施",
|
||||
subject_name: "受试者",
|
||||
subject_code: "受试者缩写号",
|
||||
subject_no: "参与者编号",
|
||||
description: "问题描述",
|
||||
action_taken: "采取措施",
|
||||
follow_up_progress: "跟进计划及进展",
|
||||
@@ -166,8 +431,75 @@ const auditFieldLabelMap: Record<string, string> = {
|
||||
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 => {
|
||||
@@ -202,14 +534,44 @@ const compactObjectValue = (value: Record<string, any>): string => {
|
||||
return entries.length > 3 ? `${rendered} 等${entries.length}项` : rendered;
|
||||
};
|
||||
|
||||
const toReadableFieldLabel = (key: string): string => {
|
||||
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 toReadableValue = (key: string, value: any): string => {
|
||||
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 ? "启用" : "停用";
|
||||
@@ -219,14 +581,14 @@ const toReadableValue = (key: string, value: any): string => {
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
if (!text) return "未填写";
|
||||
if (key.toLowerCase().includes("status")) {
|
||||
return getDictLabel(statusDict, text) || text;
|
||||
}
|
||||
return text;
|
||||
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)).join("、");
|
||||
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") {
|
||||
@@ -235,7 +597,12 @@ const toReadableValue = (key: string, value: any): string => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>) => {
|
||||
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 || {};
|
||||
@@ -244,20 +611,16 @@ const buildDiffText = (before?: Record<string, any>, after?: Record<string, any>
|
||||
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);
|
||||
const label = toReadableFieldLabel(key, entityType);
|
||||
const prevText = toReadableValue(key, prev, userMap);
|
||||
const nextText = toReadableValue(key, next, userMap);
|
||||
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),
|
||||
};
|
||||
const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type);
|
||||
let detailObj: any = {};
|
||||
if (raw.detail) {
|
||||
try {
|
||||
@@ -268,18 +631,39 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
}
|
||||
const before = detailObj.before;
|
||||
const after = detailObj.after;
|
||||
const fallbackDescription = typeof detailObj.description === "string" ? detailObj.description.trim() : "";
|
||||
let diffText = detailObj.diffText || buildDiffText(before, 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 readableDiffText = (Array.isArray(diffText) ? diffText : diffText ? [String(diffText)] : [])
|
||||
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,
|
||||
@@ -290,8 +674,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
targetType: raw.entity_type || "",
|
||||
targetTypeLabel: dict.targetLabel,
|
||||
targetId: raw.entity_id || "",
|
||||
targetName: detailObj.targetName || (isUuidText(raw.entity_id) ? "" : raw.entity_id || ""),
|
||||
actionText: dict.actionText,
|
||||
targetName,
|
||||
actionText,
|
||||
before,
|
||||
after,
|
||||
diffText: readableDiffText,
|
||||
@@ -302,8 +686,6 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -238,8 +238,12 @@ export const TEXT = {
|
||||
ISSUE_STATUS_CHANGED: { label: "风险/问题状态变更", actionText: "更新了风险/问题状态", targetLabel: "风险/问题" },
|
||||
UNAUTHORIZED_ACTION_ATTEMPT: { label: "越权操作尝试", actionText: "尝试执行无权限操作", targetLabel: "系统资源" },
|
||||
INVALID_STATE_TRANSITION_ATTEMPT: { label: "非法状态流转尝试", actionText: "尝试执行非法状态流转", targetLabel: "业务对象" },
|
||||
PROJECT_MEMBER_ADDED: { label: "项目成员新增", actionText: "添加了项目成员", targetLabel: "项目成员" },
|
||||
PROJECT_MEMBER_UPDATED: { label: "项目成员变更", actionText: "调整了项目成员", targetLabel: "项目成员" },
|
||||
PROJECT_MEMBER_REMOVED: { label: "项目成员移除", actionText: "移除了项目成员", targetLabel: "项目成员" },
|
||||
SITE_CRA_BOUND: { label: "中心 CRA 绑定", actionText: "调整了中心与 CRA 的绑定", targetLabel: "中心" },
|
||||
SITE_CREATED: { label: "新增中心", actionText: "新增了中心", targetLabel: "中心" },
|
||||
SITE_UPDATED: { label: "更新中心", actionText: "更新了中心", targetLabel: "中心" },
|
||||
SITE_STATUS_CHANGED: { label: "中心状态变更", actionText: "更新了中心信息", targetLabel: "中心" },
|
||||
AUDIT_EXPORT_SYSTEM: { label: "导出系统审计", actionText: "导出了系统审计日志", targetLabel: "审计日志" },
|
||||
AUDIT_EXPORT_PROJECT: { label: "导出项目审计", actionText: "导出了项目审计日志", targetLabel: "审计日志" },
|
||||
|
||||
@@ -29,6 +29,19 @@ describe("admin project route permissions", () => {
|
||||
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
|
||||
});
|
||||
|
||||
it("checks admin project permissions against query project id when present", () => {
|
||||
const source = readRouter();
|
||||
const accessStart = source.indexOf("const ensureAdminProjectAccess");
|
||||
const accessEnd = source.indexOf("const ensureRouteProjectMember", accessStart);
|
||||
const accessBlock = source.slice(accessStart, accessEnd);
|
||||
|
||||
expect(accessBlock).toContain("const targetProjectId = getTargetProjectId(to);");
|
||||
expect(accessBlock).toContain("studyStore.currentStudy?.id !== targetProjectId");
|
||||
expect(accessBlock).toContain("fetchStudyDetail(targetProjectId)");
|
||||
expect(accessBlock).toContain("fetchApiEndpointPermissions(targetProjectId)");
|
||||
expect(accessBlock).not.toContain("typeof to.params.projectId");
|
||||
});
|
||||
|
||||
it("guards project permission configuration with the system-level project config permission", () => {
|
||||
const source = readRouter();
|
||||
const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"');
|
||||
|
||||
@@ -454,11 +454,11 @@ const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
|
||||
const permission = to.meta.adminProjectPermission as { module: string; action: "read" | "write" } | undefined;
|
||||
if (!permission) return true;
|
||||
|
||||
const routeProjectId = typeof to.params.projectId === "string" ? to.params.projectId : "";
|
||||
if (routeProjectId && studyStore.currentStudy?.id !== routeProjectId) {
|
||||
const targetProjectId = getTargetProjectId(to);
|
||||
if (targetProjectId && studyStore.currentStudy?.id !== targetProjectId) {
|
||||
const [{ data: project }, { data: permissions }] = await Promise.all([
|
||||
fetchStudyDetail(routeProjectId),
|
||||
fetchApiEndpointPermissions(routeProjectId),
|
||||
fetchStudyDetail(targetProjectId),
|
||||
fetchApiEndpointPermissions(targetProjectId),
|
||||
]);
|
||||
studyStore.setCurrentStudy(project);
|
||||
studyStore.currentPermissions = permissions;
|
||||
|
||||
@@ -5,6 +5,29 @@ import { resolve } from "node:path";
|
||||
const readAuditLogsView = () => readFileSync(resolve(__dirname, "./AuditLogs.vue"), "utf8");
|
||||
|
||||
describe("audit logs access", () => {
|
||||
it("loads audit logs from the selected project context", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain("const selectedStudy = ref<Study | null>(null)");
|
||||
expect(source).toContain("const selectedStudyId = computed({");
|
||||
expect(source).toContain("get: () => selectedStudy.value?.id || \"\"");
|
||||
expect(source).toContain("set: (studyId: string) => {");
|
||||
expect(source).toContain("await fetchAuditLogs(selectedStudyId.value, params)");
|
||||
expect(source).toContain("await fetchApiEndpointPermissions(selectedStudyId.value)");
|
||||
expect(source).toContain("await createAuditEvent(selectedStudyId.value,");
|
||||
expect(source).not.toContain("await fetchAuditLogs(study.currentStudy.id, params)");
|
||||
});
|
||||
|
||||
it("limits non-admin project choices to PM-owned projects", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain('const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");');
|
||||
expect(source).toContain("studies.value = availableItems;");
|
||||
expect(source).toContain("availableItems.find((item) => item.id === queryProjectId)");
|
||||
expect(source).toContain("availableItems.find((item) => item.id === study.currentStudy?.id)");
|
||||
expect(source).toContain("availableItems[0]");
|
||||
});
|
||||
|
||||
it("allows only admins or authorized PMs to use audit export", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
@@ -16,11 +39,75 @@ describe("audit logs access", () => {
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]');
|
||||
expect(source).toContain('if (scope === "project" && !canProjectExport.value) return;');
|
||||
expect(source).toContain("if (!canProjectExport.value) return;");
|
||||
expect(source).toContain('action: "AUDIT_EXPORT_PROJECT"');
|
||||
expect(source).not.toContain('command="system"');
|
||||
expect(source).not.toMatch(/auth\.user\?\.role/);
|
||||
expect(source).not.toContain('!== "ADMIN"');
|
||||
});
|
||||
|
||||
it("exports all matching audit log pages instead of a fixed first page", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain("const fetchAuditLogPages = async");
|
||||
expect(source).toContain("while (true)");
|
||||
expect(source).toContain("if (items.length < batchLimit) break;");
|
||||
expect(source).not.toContain("limit: 2000");
|
||||
});
|
||||
|
||||
it("keeps the audit table compact by removing the duplicate content column", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).not.toContain('prop="actionText"');
|
||||
expect(source).not.toContain("TEXT.modules.adminAuditLogs.columns.action");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.target");
|
||||
expect(source).toContain("TEXT.modules.adminAuditLogs.columns.diff");
|
||||
});
|
||||
|
||||
it("shows actor names without avatar icons in the audit table", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain('<span class="actor-name">{{ scope.row.actorName }}</span>');
|
||||
expect(source).not.toContain("actor-avatar");
|
||||
expect(source).not.toContain("getInitials(scope.row.actorName)");
|
||||
});
|
||||
|
||||
it("lets target and detail columns absorb remaining table width", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">');
|
||||
expect(source).toContain('<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">');
|
||||
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.target" width="240"');
|
||||
expect(source).not.toContain(':label="TEXT.modules.adminAuditLogs.columns.diff" width="360"');
|
||||
});
|
||||
|
||||
it("parses diff lines by the last field separator before the arrow", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toMatch(/line\.match\(\/\^\(\.\+\)\[::\]\\s\*\(\.\*\?\)\\s\*->\\s\*\(\.\+\)\$\/\)/);
|
||||
expect(source).not.toMatch(/line\.match\(\/\^\(\.\+\?\):\(\.\+\?\)\\s\*->\\s\*\(\.\+\)\$\/\)/);
|
||||
});
|
||||
|
||||
it("renders detail object and action as two cards in one row", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain('<span class="meta-label">对象</span>');
|
||||
expect(source).toContain('<span class="meta-label">动作</span>');
|
||||
expect(source).not.toContain("detail-meta-full");
|
||||
expect(source).not.toContain("meta-card-full");
|
||||
});
|
||||
|
||||
it("groups setup diff detail rows by business item path", () => {
|
||||
const source = readAuditLogsView();
|
||||
|
||||
expect(source).toContain("const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {");
|
||||
expect(source).toContain('const groupedIndex = new Map<string, DetailDiffGroup>();');
|
||||
expect(source).toContain('title: parts.slice(0, -1).join(" / "),');
|
||||
expect(source).toContain("field: parts[parts.length - 1],");
|
||||
expect(source).toContain('class="diff-group-title"');
|
||||
expect(source).toContain("{{ selectedDiffGroups.length }} 项");
|
||||
});
|
||||
|
||||
it("does not expose legacy startup ethics business labels", () => {
|
||||
const auditSource = readFileSync(resolve(__dirname, "../../audit/index.ts"), "utf8");
|
||||
const overviewSource = readFileSync(resolve(__dirname, "../ia/project-overview/overview.adapter.ts"), "utf8");
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
<!-- 筛选栏 -->
|
||||
<div class="audit-toolbar unified-action-bar">
|
||||
<el-form :inline="true" :model="filters" class="filter-form">
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="selectedStudyId" filterable :placeholder="TEXT.common.fields.projectName" @change="onProjectChange" class="filter-select-project">
|
||||
<el-option v-for="project in studies" :key="project.id" :label="project.name" :value="project.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item-form">
|
||||
<el-select v-model="filters.eventType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterEvent" @change="onServerFilterChange" class="filter-select-comp">
|
||||
<el-option v-for="opt in eventTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
@@ -81,7 +86,6 @@
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="isAdmin" command="system">{{ TEXT.modules.adminAuditLogs.exportSystem }}</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canProjectExport" command="project">{{ TEXT.modules.adminAuditLogs.exportProject }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -92,26 +96,24 @@
|
||||
<!-- 日志表格 -->
|
||||
<div class="unified-section table-section">
|
||||
<el-table :data="logs" v-loading="loading" class="audit-table" style="width: 100%" table-layout="fixed">
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="155">
|
||||
<el-table-column prop="timestamp" :label="TEXT.modules.adminAuditLogs.columns.time" width="150">
|
||||
<template #default="scope">
|
||||
<span class="time-text">{{ displayDateTime(scope.row.timestamp) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="130">
|
||||
<el-table-column prop="actorName" :label="TEXT.modules.adminAuditLogs.columns.actor" width="120">
|
||||
<template #default="scope">
|
||||
<div class="actor-cell">
|
||||
<div class="actor-avatar">{{ getInitials(scope.row.actorName) }}</div>
|
||||
<span class="actor-name">{{ scope.row.actorName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="140">
|
||||
<el-table-column prop="eventLabel" :label="TEXT.modules.adminAuditLogs.columns.event" width="145">
|
||||
<template #default="scope">
|
||||
<span class="event-tag">{{ scope.row.eventLabel }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actionText" :label="TEXT.modules.adminAuditLogs.columns.action" show-overflow-tooltip />
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.target" min-width="240">
|
||||
<template #default="scope">
|
||||
<div class="target-cell">
|
||||
<span v-if="scope.row.targetTypeLabel" class="target-text">
|
||||
@@ -121,7 +123,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff" min-width="360">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.diffText?.length" class="diff-container">
|
||||
<div v-for="(line, idx) in getDiffPreview(scope.row)" :key="idx" class="diff-line">{{ formatDiffLine(line) }}</div>
|
||||
@@ -133,7 +135,7 @@
|
||||
<span v-else class="text-muted">{{ TEXT.audit.emptyValue }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="80" align="center">
|
||||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.result" width="76" align="center">
|
||||
<template #default="scope">
|
||||
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
|
||||
{{ scope.row.resultLabel }}
|
||||
@@ -199,12 +201,12 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta-grid detail-meta-full">
|
||||
<div class="meta-card meta-card-full">
|
||||
<div class="detail-meta-grid">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">对象</span>
|
||||
<span class="meta-value">{{ selectedLog.targetTypeLabel || TEXT.common.fallback }}<span v-if="selectedLog.targetName">:{{ selectedLog.targetName }}</span></span>
|
||||
</div>
|
||||
<div class="meta-card meta-card-full">
|
||||
<div class="meta-card">
|
||||
<span class="meta-label">动作</span>
|
||||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||||
</div>
|
||||
@@ -215,22 +217,27 @@
|
||||
<div class="detail-section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
变更明细
|
||||
<span class="diff-count" v-if="selectedLog.diffText?.length">{{ selectedLog.diffText.length }} 条</span>
|
||||
<span class="diff-count" v-if="selectedDiffGroups.length">{{ selectedDiffGroups.length }} 项</span>
|
||||
</div>
|
||||
<el-scrollbar max-height="55vh">
|
||||
<div v-if="selectedLog.diffText?.length" class="detail-lines">
|
||||
<div v-for="(line, idx) in selectedLog.diffText" :key="idx" class="detail-line-row">
|
||||
<div v-if="selectedDiffGroups.length" class="detail-lines">
|
||||
<div v-for="(group, idx) in selectedDiffGroups" :key="`${group.title}-${idx}`" class="detail-line-row">
|
||||
<span class="detail-line-index">{{ Number(idx) + 1 }}</span>
|
||||
<div class="detail-line-content">
|
||||
<template v-if="parseDiffLine(formatDiffLine(line))">
|
||||
<span class="diff-field">{{ parseDiffLine(formatDiffLine(line))!.field }}</span>
|
||||
<div class="diff-values">
|
||||
<span class="diff-old">{{ parseDiffLine(formatDiffLine(line))!.oldVal }}</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
<span class="diff-new">{{ parseDiffLine(formatDiffLine(line))!.newVal }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="detail-line-text">{{ formatDiffLine(line) }}</span>
|
||||
<span v-if="group.title" class="diff-group-title">{{ group.title }}</span>
|
||||
<div class="diff-group-lines">
|
||||
<template v-for="(item, itemIdx) in group.items" :key="itemIdx">
|
||||
<div v-if="item.parsed" class="diff-item-row">
|
||||
<span class="diff-field">{{ item.parsed.field }}</span>
|
||||
<div class="diff-values">
|
||||
<span class="diff-old">{{ item.parsed.oldVal }}</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12" class="diff-arrow"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
<span class="diff-new">{{ item.parsed.newVal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="detail-line-text">{{ item.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,10 +254,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ArrowDown } from "@element-plus/icons-vue";
|
||||
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
|
||||
import { fetchStudies } from "../../api/studies";
|
||||
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
|
||||
import { fetchUsers } from "../../api/users";
|
||||
import { listMembers } from "../../api/members";
|
||||
@@ -263,15 +271,19 @@ import { displayDateTime } from "../../utils/display";
|
||||
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
import type { Study } from "../../types/api";
|
||||
|
||||
const study = useStudyStore();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const exportLoading = ref(false);
|
||||
const logs = ref<any[]>([]);
|
||||
const users = ref<any[]>([]);
|
||||
const studies = ref<Study[]>([]);
|
||||
const selectedStudy = ref<Study | null>(null);
|
||||
const allLogs = ref<any[]>([]);
|
||||
const permissionMatrix = ref<any | null>(null);
|
||||
const page = ref(1);
|
||||
@@ -292,13 +304,6 @@ const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCC
|
||||
const failCount = computed(() => allLogs.value.filter(l => l.result === 'FAIL').length);
|
||||
const operatorCount = computed(() => new Set(allLogs.value.map(l => l.actorId || l.actorName)).size);
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const formatDiffLine = (line: any): string => {
|
||||
if (typeof line === 'string') return line;
|
||||
if (line === null || line === undefined) return '-';
|
||||
@@ -310,11 +315,62 @@ const formatDiffLine = (line: any): string => {
|
||||
};
|
||||
|
||||
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
|
||||
const match = line.match(/^(.+?):(.+?)\s*->\s*(.+)$/);
|
||||
const match = line.match(/^(.+)[::]\s*(.*?)\s*->\s*(.+)$/);
|
||||
if (!match) return null;
|
||||
return { field: match[1], oldVal: match[2], newVal: match[3] };
|
||||
};
|
||||
|
||||
type DetailDiffItem = {
|
||||
text: string;
|
||||
parsed: { field: string; oldVal: string; newVal: string } | null;
|
||||
};
|
||||
|
||||
type DetailDiffGroup = {
|
||||
title: string;
|
||||
items: DetailDiffItem[];
|
||||
};
|
||||
|
||||
const splitGroupedDiffField = (field: string): { title: string; field: string } | null => {
|
||||
const parts = String(field || "")
|
||||
.split("/")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
return {
|
||||
title: parts.slice(0, -1).join(" / "),
|
||||
field: parts[parts.length - 1],
|
||||
};
|
||||
};
|
||||
|
||||
const buildDetailDiffGroups = (lines: any[]): DetailDiffGroup[] => {
|
||||
const groups: DetailDiffGroup[] = [];
|
||||
const groupedIndex = new Map<string, DetailDiffGroup>();
|
||||
|
||||
lines.forEach((line) => {
|
||||
const text = formatDiffLine(line);
|
||||
const parsed = parseDiffLine(text);
|
||||
const groupedField = parsed ? splitGroupedDiffField(parsed.field) : null;
|
||||
|
||||
if (!parsed || !groupedField) {
|
||||
groups.push({ title: "", items: [{ text, parsed }] });
|
||||
return;
|
||||
}
|
||||
|
||||
let group = groupedIndex.get(groupedField.title);
|
||||
if (!group) {
|
||||
group = { title: groupedField.title, items: [] };
|
||||
groupedIndex.set(groupedField.title, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push({
|
||||
text,
|
||||
parsed: { ...parsed, field: groupedField.field },
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
|
||||
value,
|
||||
label: cfg.label,
|
||||
@@ -324,8 +380,20 @@ const resolveUserDisplayName = (u: any): string => {
|
||||
};
|
||||
|
||||
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
|
||||
const selectedDiffGroups = computed(() => buildDetailDiffGroups(selectedLog.value?.diffText || []));
|
||||
const selectedStudyId = computed({
|
||||
get: () => selectedStudy.value?.id || "",
|
||||
set: (studyId: string) => {
|
||||
selectedStudy.value = studies.value.find((item) => item.id === studyId) || null;
|
||||
},
|
||||
});
|
||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const projectRole = computed(() => {
|
||||
const role = selectedStudy.value?.id === study.currentStudy?.id
|
||||
? study.currentStudyRole
|
||||
: (selectedStudy.value as any)?.role_in_study || null;
|
||||
return getProjectRole(selectedStudy.value, role);
|
||||
});
|
||||
const canProjectExport = computed(() => {
|
||||
if (isAdmin.value) return true;
|
||||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]);
|
||||
@@ -336,14 +404,31 @@ const canAccessAuditLogs = computed(() => {
|
||||
});
|
||||
|
||||
const ensureAccess = () => {
|
||||
if (!selectedStudy.value) {
|
||||
router.replace("/admin/projects");
|
||||
return;
|
||||
}
|
||||
if (!canAccessAuditLogs.value) {
|
||||
router.replace(study.currentStudy ? "/project/overview" : "/admin/projects");
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailableStudies = async () => {
|
||||
const { data } = await fetchStudies();
|
||||
const items = ((data as any).items || []) as Study[];
|
||||
const availableItems = isAdmin.value ? items : items.filter((item) => item.role_in_study === "PM");
|
||||
studies.value = availableItems;
|
||||
const queryProjectId = typeof route.query.projectId === "string" ? route.query.projectId : "";
|
||||
selectedStudy.value =
|
||||
availableItems.find((item) => item.id === queryProjectId) ||
|
||||
availableItems.find((item) => item.id === study.currentStudy?.id) ||
|
||||
availableItems[0] ||
|
||||
null;
|
||||
};
|
||||
|
||||
const loadPermissionMatrix = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
const { data } = await fetchApiEndpointPermissions(study.currentStudy.id);
|
||||
if (!selectedStudyId.value) return;
|
||||
const { data } = await fetchApiEndpointPermissions(selectedStudyId.value);
|
||||
permissionMatrix.value = data;
|
||||
};
|
||||
|
||||
@@ -352,8 +437,8 @@ const loadUsers = async () => {
|
||||
if (isAdmin.value) {
|
||||
const { data } = await fetchUsers({ limit: 500 });
|
||||
users.value = Array.isArray(data) ? data : (data as any).items || [];
|
||||
} else if (study.currentStudy) {
|
||||
const { data } = await listMembers(study.currentStudy.id, { limit: 500 });
|
||||
} else if (selectedStudyId.value) {
|
||||
const { data } = await listMembers(selectedStudyId.value, { limit: 500 });
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
users.value = items.map((m: any) => ({
|
||||
id: m.user_id,
|
||||
@@ -365,30 +450,37 @@ const loadUsers = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAuditLogPages = async (baseParams: Record<string, any> = {}) => {
|
||||
const batchLimit = 500;
|
||||
let skip = 0;
|
||||
const allItems: any[] = [];
|
||||
const params: Record<string, any> = {
|
||||
...baseParams,
|
||||
skip,
|
||||
limit: batchLimit,
|
||||
};
|
||||
while (true) {
|
||||
params.skip = skip;
|
||||
const { data } = await fetchAuditLogs(selectedStudyId.value, params);
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
allItems.push(...items);
|
||||
if (items.length < batchLimit) break;
|
||||
skip += items.length;
|
||||
}
|
||||
return allItems;
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!study.currentStudy) return;
|
||||
if (!selectedStudyId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!permissionMatrix.value) {
|
||||
await loadPermissionMatrix();
|
||||
}
|
||||
const batchLimit = 500;
|
||||
let skip = 0;
|
||||
const allItems: any[] = [];
|
||||
const params: Record<string, any> = {
|
||||
skip,
|
||||
limit: batchLimit,
|
||||
const allItems = await fetchAuditLogPages({
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
while (true) {
|
||||
params.skip = skip;
|
||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
allItems.push(...items);
|
||||
if (items.length < batchLimit) break;
|
||||
skip += items.length;
|
||||
}
|
||||
});
|
||||
enrichLogs(allItems);
|
||||
refreshPagedLogs();
|
||||
} catch (e: any) {
|
||||
@@ -448,6 +540,23 @@ const onLocalFilterChange = () => {
|
||||
refreshPagedLogs();
|
||||
};
|
||||
|
||||
const onProjectChange = async () => {
|
||||
permissionMatrix.value = null;
|
||||
users.value = [];
|
||||
allLogs.value = [];
|
||||
logs.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
await router.replace({
|
||||
path: "/admin/audit-logs",
|
||||
query: { ...route.query, projectId: selectedStudyId.value || undefined },
|
||||
});
|
||||
await loadPermissionMatrix().catch(() => {});
|
||||
ensureAccess();
|
||||
await loadUsers();
|
||||
await loadLogs();
|
||||
};
|
||||
|
||||
const onPageChange = (p: number) => {
|
||||
page.value = p;
|
||||
refreshPagedLogs();
|
||||
@@ -470,17 +579,13 @@ const openDetail = (log: any) => {
|
||||
};
|
||||
|
||||
const fetchAllForExport = async () => {
|
||||
if (!study.currentStudy) return [];
|
||||
if (!selectedStudyId.value) return [];
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
skip: 0,
|
||||
limit: 2000,
|
||||
const items = await fetchAuditLogPages({
|
||||
action: filters.value.eventType || undefined,
|
||||
operator_id: filters.value.operatorId || undefined,
|
||||
};
|
||||
const { data } = await fetchAuditLogs(study.currentStudy.id, params);
|
||||
const items = Array.isArray(data) ? data : (data as any).items || [];
|
||||
});
|
||||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||||
acc[cur.id] = resolveUserDisplayName(cur);
|
||||
return acc;
|
||||
@@ -504,13 +609,13 @@ const fetchAllForExport = async () => {
|
||||
};
|
||||
|
||||
const handleExportCommand = (command: string) => {
|
||||
confirmExport(command as "system" | "project");
|
||||
if (command === "project") confirmExport();
|
||||
};
|
||||
|
||||
const confirmExport = async (scope: "system" | "project") => {
|
||||
const currentStudy = study.currentStudy;
|
||||
const confirmExport = async () => {
|
||||
const currentStudy = selectedStudy.value;
|
||||
if (!currentStudy) return;
|
||||
if (scope === "project" && !canProjectExport.value) return;
|
||||
if (!canProjectExport.value) return;
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.adminAuditLogs.exportConfirm,
|
||||
TEXT.modules.adminAuditLogs.exportConfirmTitle,
|
||||
@@ -522,20 +627,10 @@ const confirmExport = async (scope: "system" | "project") => {
|
||||
ElMessage.warning(TEXT.modules.adminAuditLogs.exportEmpty);
|
||||
return;
|
||||
}
|
||||
const fileName =
|
||||
scope === "system"
|
||||
? `${TEXT.modules.adminAuditLogs.exportSystemName}_${new Date().toISOString().slice(0, 10)}`
|
||||
: `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||||
const fileName = `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||||
exportAuditCsv(events, { fileName });
|
||||
await createAuditEvent(currentStudy.id, {
|
||||
action: scope === "system" ? "AUDIT_EXPORT_SYSTEM" : "AUDIT_EXPORT_PROJECT",
|
||||
entity_type: "audit_log",
|
||||
entity_id: null,
|
||||
detail: JSON.stringify(
|
||||
{ targetName: currentStudy.name, scope, exportedCount: events.length },
|
||||
null,
|
||||
0
|
||||
),
|
||||
await createAuditEvent(selectedStudyId.value, {
|
||||
action: "AUDIT_EXPORT_PROJECT",
|
||||
}).catch(() => null);
|
||||
};
|
||||
|
||||
@@ -543,6 +638,7 @@ onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
await loadAvailableStudies().catch(() => {});
|
||||
await loadPermissionMatrix().catch(() => {});
|
||||
ensureAccess();
|
||||
await loadUsers();
|
||||
@@ -615,6 +711,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.filter-select-comp { width: 132px; }
|
||||
.filter-select-project { width: 180px; }
|
||||
.filter-select-result { width: 96px; }
|
||||
.date-range-picker-comp { width: 248px !important; }
|
||||
.filter-spacer { flex: 1; }
|
||||
@@ -637,6 +734,12 @@ onMounted(async () => {
|
||||
/* 表格 */
|
||||
.table-section { padding: 0; }
|
||||
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
|
||||
.audit-table :deep(.el-table__cell) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.audit-table :deep(.cell) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
@@ -645,26 +748,11 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.actor-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actor-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -692,6 +780,23 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.target-text {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.target-text strong {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.target-text .text-muted {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
|
||||
|
||||
.result-badge {
|
||||
@@ -712,6 +817,7 @@ onMounted(async () => {
|
||||
color: var(--ctms-text-regular);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -775,10 +881,6 @@ onMounted(async () => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-meta-full {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -789,10 +891,6 @@ onMounted(async () => {
|
||||
background: var(--ctms-neutral-100);
|
||||
}
|
||||
|
||||
.meta-card-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 11px;
|
||||
color: var(--ctms-text-secondary);
|
||||
@@ -875,12 +973,35 @@ onMounted(async () => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diff-group-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-primary);
|
||||
margin-bottom: 8px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.diff-group-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-item-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 128px) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.diff-field {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-primary);
|
||||
margin-bottom: 4px;
|
||||
color: var(--ctms-text-regular);
|
||||
line-height: 1.8;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.diff-values {
|
||||
@@ -936,5 +1057,6 @@ onMounted(async () => {
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.filter-form { flex-wrap: wrap; }
|
||||
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -120,4 +120,10 @@ describe("project management access", () => {
|
||||
expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
|
||||
expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
|
||||
});
|
||||
|
||||
it("opens audit logs with an explicit project id", () => {
|
||||
const projects = readProjects();
|
||||
|
||||
expect(projects).toContain('await enterStudy(row, `/admin/audit-logs?projectId=${row.id}`)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ const goPermissions = (row: Study) => {
|
||||
};
|
||||
|
||||
const goAuditLogs = async (row: Study) => {
|
||||
await enterStudy(row, "/admin/audit-logs");
|
||||
await enterStudy(row, `/admin/audit-logs?projectId=${row.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (study: Study) => {
|
||||
|
||||
Reference in New Issue
Block a user