完善审计日志展示与导出权限
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user