完善审计日志展示与导出权限

This commit is contained in:
Cheng Zhou
2026-06-10 10:20:04 +08:00
parent 1fef12cd86
commit ea3f19e241
33 changed files with 1862 additions and 240 deletions
+1 -4
View File
@@ -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);
+455 -2
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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: "审计日志" },
+13
View File
@@ -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"');
+4 -4
View File
@@ -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;
+88 -1
View File
@@ -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");
+227 -105
View File
@@ -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}`)');
});
});
+1 -1
View File
@@ -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) => {