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

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
+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) => {