Files
ctms/frontend/src/views/admin/AuditLogs.vue
T
2026-05-21 15:48:19 +08:00

940 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page page--flush">
<div class="main-content-flat unified-shell">
<!-- 统计概览 -->
<div class="stats-row">
<div class="stat-card stat-card--total">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ total }}</span>
<span class="stat-label">日志总数</span>
</div>
</div>
<div class="stat-card stat-card--success">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ successCount }}</span>
<span class="stat-label">{{ TEXT.audit.resultSuccess }}</span>
</div>
</div>
<div class="stat-card stat-card--fail">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ failCount }}</span>
<span class="stat-label">{{ TEXT.audit.resultFail }}</span>
</div>
</div>
<div class="stat-card stat-card--operators">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-body">
<span class="stat-value">{{ operatorCount }}</span>
<span class="stat-label">操作人数</span>
</div>
</div>
</div>
<!-- 筛选栏 -->
<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="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" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.operatorId" clearable :placeholder="TEXT.modules.adminAuditLogs.filterOperator" filterable @change="onServerFilterChange" class="filter-select-comp">
<el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
</el-select>
</div>
<div class="filter-item-form">
<el-select v-model="filters.result" clearable :placeholder="TEXT.modules.adminAuditLogs.filterResult" @change="onLocalFilterChange" class="filter-select-result">
<el-option :label="TEXT.audit.resultSuccess" value="SUCCESS" />
<el-option :label="TEXT.audit.resultFail" value="FAIL" />
</el-select>
</div>
<div class="filter-item-form">
<el-date-picker
v-model="filters.range"
type="daterange"
unlink-panels
:start-placeholder="TEXT.modules.adminAuditLogs.rangeStart"
:end-placeholder="TEXT.modules.adminAuditLogs.rangeEnd"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="date-range-picker-comp"
@change="onLocalFilterChange"
/>
</div>
<div class="filter-spacer"></div>
<el-dropdown trigger="click" @command="handleExportCommand" class="audit-export-dropdown">
<el-button plain class="audit-export-btn" :loading="exportLoading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</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>
</el-dropdown>
</el-form>
</div>
<!-- 日志表格 -->
<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">
<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">
<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">
<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">
<template #default="scope">
<div class="target-cell">
<span v-if="scope.row.targetTypeLabel" class="target-text">
<strong>{{ scope.row.targetTypeLabel }}</strong><span v-if="scope.row.targetName" class="text-muted">{{ scope.row.targetName }}</span>
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.diff">
<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>
<div class="diff-more-row" v-if="scope.row.diffText.length > DIFF_PREVIEW_LIMIT">
<span class="diff-more-text">其余 {{ scope.row.diffText.length - DIFF_PREVIEW_LIMIT }} </span>
<el-button link type="primary" size="small" @click="openDetail(scope.row)">{{ TEXT.common.actions.view }}</el-button>
</div>
</div>
<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">
<template #default="scope">
<span class="result-badge" :class="scope.row.result === 'SUCCESS' ? 'result--success' : 'result--fail'">
{{ scope.row.resultLabel }}
</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20]"
:total="total"
layout="prev, pager, next, sizes, total"
small
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div>
</div>
<!-- 详情抽屉 -->
<el-drawer
v-if="detailVisible"
v-model="detailVisible"
direction="rtl"
size="580px"
:close-on-click-modal="false"
:show-close="false"
class="audit-detail-drawer"
>
<template #header>
<div class="drawer-header">
<div class="drawer-title-row">
<div class="drawer-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="18" height="18"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<span class="drawer-title">审计详情</span>
</div>
<el-button @click="detailVisible = false" size="small">{{ TEXT.common.actions.close }}</el-button>
</div>
</template>
<template v-if="selectedLog">
<!-- 基本信息 -->
<div class="detail-meta-grid">
<div class="meta-card">
<span class="meta-label">时间</span>
<span class="meta-value">{{ displayDateTime(selectedLog.timestamp) }}</span>
</div>
<div class="meta-card">
<span class="meta-label">操作人</span>
<span class="meta-value">{{ selectedLog.actorName || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">事件</span>
<span class="meta-value">{{ selectedLog.eventLabel || TEXT.common.fallback }}</span>
</div>
<div class="meta-card">
<span class="meta-label">结果</span>
<span class="meta-value">
<span class="result-badge" :class="selectedLog.result === 'SUCCESS' ? 'result--success' : 'result--fail'">{{ selectedLog.resultLabel }}</span>
</span>
</div>
</div>
<div class="detail-meta-grid detail-meta-full">
<div class="meta-card meta-card-full">
<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">
<span class="meta-label">动作</span>
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
</div>
</div>
<!-- 变更明细 -->
<div class="detail-section">
<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>
</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">
<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>
</div>
</div>
</div>
<div v-else class="empty-diff">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32" style="color:var(--ctms-text-disabled)"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-muted">无变更记录</span>
</div>
</el-scrollbar>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown } from "@element-plus/icons-vue";
import { createAuditEvent, fetchAuditLogs } from "../../api/auditLogs";
import { fetchApiEndpointPermissions } from "../../api/projectPermissions";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import { auditDict, normalizeAuditEvent } from "../../audit";
import { useStudyStore } from "../../store/study";
import { useAuthStore } from "../../store/auth";
import { roleDict, getDictLabel } from "../../dictionaries";
import { exportAuditCsv } from "../../audit/export/auditExportService";
import { displayDateTime } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const loading = ref(false);
const exportLoading = ref(false);
const logs = ref<any[]>([]);
const users = ref<any[]>([]);
const allLogs = ref<any[]>([]);
const permissionMatrix = ref<any | null>(null);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const detailVisible = ref(false);
const selectedLog = ref<any | null>(null);
const DIFF_PREVIEW_LIMIT = 2;
const filters = ref({
eventType: "",
operatorId: "",
result: "",
range: [],
});
const successCount = computed(() => allLogs.value.filter(l => (l.result || 'SUCCESS') === 'SUCCESS').length);
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 '-';
try {
return JSON.stringify(line, null, 0);
} catch {
return String(line);
}
};
const parseDiffLine = (line: string): { field: string; oldVal: string; newVal: string } | null => {
const match = line.match(/^(.+?)(.+?)\s*->\s*(.+)$/);
if (!match) return null;
return { field: match[1], oldVal: match[2], newVal: match[3] };
};
const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({
value,
label: cfg.label,
}));
const resolveUserDisplayName = (u: any): string => {
return u?.full_name || u?.display_name || u?.username || u?.email || u?.id || TEXT.common.fallback;
};
const userOptions = computed(() => users.value.map((u: any) => ({ label: resolveUserDisplayName(u), value: u.id })));
const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
});
const ensureAccess = () => {
if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/admin/projects");
}
};
const loadPermissionMatrix = async () => {
if (!study.currentStudy) return;
const { data } = await fetchApiEndpointPermissions(study.currentStudy.id);
permissionMatrix.value = data;
};
const loadUsers = async () => {
try {
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 });
const items = Array.isArray(data) ? data : (data as any).items || [];
users.value = items.map((m: any) => ({
id: m.user_id,
full_name: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
}));
}
} catch {
users.value = [];
}
};
const loadLogs = async () => {
if (!study.currentStudy) 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,
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) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.loadFailed);
} finally {
loading.value = false;
}
};
const enrichLogs = (items: any[]) => {
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = resolveUserDisplayName(cur);
return acc;
}, {});
allLogs.value = items
.map((log) => normalizeAuditEvent(log, userMap))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
if (users.value.length === 0) {
const byActor = new Map<string, string>();
allLogs.value.forEach((log) => {
if (!byActor.has(log.actorId)) {
byActor.set(log.actorId, log.actorName || log.actorId);
}
});
users.value = Array.from(byActor.entries()).map(([id, full_name]) => ({ id, full_name }));
}
};
const filterLogs = (items: any[]) =>
items.filter((log) => {
if (filters.value.result && (log.result || "SUCCESS") !== filters.value.result) return false;
if (filters.value.range?.length === 2) {
const ts = new Date(log.timestamp);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
const refreshPagedLogs = () => {
const filtered = filterLogs(allLogs.value);
total.value = filtered.length;
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value));
if (page.value > maxPage) page.value = maxPage;
const start = (page.value - 1) * pageSize.value;
logs.value = filtered.slice(start, start + pageSize.value);
};
const onServerFilterChange = () => {
page.value = 1;
loadLogs();
};
const onLocalFilterChange = () => {
page.value = 1;
refreshPagedLogs();
};
const onPageChange = (p: number) => {
page.value = p;
refreshPagedLogs();
};
const onPageSizeChange = (size: number) => {
pageSize.value = size;
page.value = 1;
refreshPagedLogs();
};
const getDiffPreview = (log: any) => {
const lines = Array.isArray(log?.diffText) ? log.diffText : [];
return lines.slice(0, DIFF_PREVIEW_LIMIT);
};
const openDetail = (log: any) => {
selectedLog.value = log;
detailVisible.value = true;
};
const fetchAllForExport = async () => {
if (!study.currentStudy) return [];
exportLoading.value = true;
try {
const params: Record<string, any> = {
skip: 0,
limit: 2000,
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;
}, {});
const filtered = items.filter((log: any) => {
if (filters.value.range?.length === 2) {
const ts = new Date(log.created_at);
const start = new Date(filters.value.range[0]);
const end = new Date(filters.value.range[1]);
if (ts < start || ts > end) return false;
}
return true;
});
return filtered.map((log: any) => normalizeAuditEvent(log, userMap));
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminAuditLogs.exportLoadFailed);
return [];
} finally {
exportLoading.value = false;
}
};
const handleExportCommand = (command: string) => {
confirmExport(command as "system" | "project");
};
const confirmExport = async (scope: "system" | "project") => {
const currentStudy = study.currentStudy;
if (!currentStudy) return;
const ok = await ElMessageBox.confirm(
TEXT.modules.adminAuditLogs.exportConfirm,
TEXT.modules.adminAuditLogs.exportConfirmTitle,
{ type: "warning", confirmButtonText: TEXT.common.actions.confirm, cancelButtonText: TEXT.common.actions.cancel }
).catch(() => null);
if (!ok) return;
const events = await fetchAllForExport();
if (!events.length) {
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)}`;
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
),
}).catch(() => null);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadPermissionMatrix().catch(() => {});
ensureAccess();
await loadUsers();
await loadLogs();
});
</script>
<style scoped>
/* 统计卡片 */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--unified-shell-divider);
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: var(--ctms-neutral-100);
transition: var(--ctms-transition);
}
.stat-card:hover {
transform: translateY(-1px);
box-shadow: var(--ctms-shadow-sm);
}
.stat-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon svg { width: 20px; height: 20px; }
.stat-card--total .stat-icon { background: #e8edf3; color: var(--ctms-primary); }
.stat-card--success .stat-icon { background: #e6f4ed; color: var(--ctms-success); }
.stat-card--fail .stat-icon { background: #fde8e8; color: var(--ctms-danger); }
.stat-card--operators .stat-icon { background: #eef2f6; color: var(--ctms-info); }
.stat-body { display: flex; flex-direction: column; }
.stat-value { font-size: 22px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
.stat-label { font-size: 12px; color: var(--ctms-text-secondary); margin-top: 2px; }
/* 筛选栏 */
.audit-toolbar {
border-bottom: 1px solid var(--unified-shell-divider);
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
flex-shrink: 0;
}
.filter-select-comp { width: 132px; }
.filter-select-result { width: 96px; }
.date-range-picker-comp { width: 248px !important; }
.filter-spacer { flex: 1; }
.audit-export-dropdown { flex-shrink: 0; }
.audit-export-btn {
border-radius: 8px !important;
font-weight: 600;
height: 32px;
color: var(--ctms-primary) !important;
border-color: #c0cdd7 !important;
background: #f0f6ff !important;
}
.audit-export-btn:hover {
color: var(--ctms-primary-hover) !important;
border-color: #9bb1c2 !important;
background: #e2edfa !important;
}
/* 表格 */
.table-section { padding: 0; }
.audit-table :deep(.el-table__inner-wrapper::before) { display: none; }
.time-text {
font-size: 12px;
color: var(--ctms-text-secondary);
font-variant-numeric: tabular-nums;
}
.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;
}
.actor-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-tag {
display: inline-flex;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
background: #f0f4f8;
color: var(--ctms-text-regular);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.target-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.text-muted { color: var(--ctms-text-secondary); font-size: 12px; }
.result-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.result--success { background: #e6f4ed; color: #166534; }
.result--fail { background: #fde8e8; color: #991b1b; }
.diff-container {
font-size: 12px;
color: var(--ctms-text-regular);
line-height: 1.5;
overflow: hidden;
}
.diff-line {
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-more-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.diff-more-text { font-size: 11px; color: var(--ctms-text-disabled); }
.pagination-wrap {
margin-top: 10px;
display: flex;
justify-content: flex-end;
padding: 8px 16px;
}
/* 详情抽屉 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.drawer-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.drawer-icon {
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--ctms-primary-light);
color: var(--ctms-primary);
display: flex;
align-items: center;
justify-content: center;
}
.drawer-title {
font-size: 17px;
font-weight: 700;
color: var(--ctms-text-main);
}
.detail-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.detail-meta-full {
grid-template-columns: 1fr;
}
.meta-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
background: var(--ctms-neutral-100);
}
.meta-card-full {
grid-column: 1 / -1;
}
.meta-label {
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.meta-value {
font-size: 13px;
color: var(--ctms-text-main);
word-break: break-word;
}
.detail-section {
margin-top: 14px;
}
.detail-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 700;
color: var(--ctms-text-main);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ctms-border-color);
}
.detail-section-title svg {
color: var(--ctms-primary);
}
.diff-count {
font-size: 11px;
font-weight: 600;
color: var(--ctms-text-secondary);
background: var(--ctms-neutral-100);
padding: 1px 6px;
border-radius: 4px;
margin-left: auto;
}
.detail-lines {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-line-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--ctms-border-color);
background: #fff;
transition: var(--ctms-transition);
}
.detail-line-row:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: var(--ctms-shadow-sm);
}
.detail-line-index {
color: var(--ctms-text-disabled);
min-width: 20px;
font-size: 11px;
font-weight: 700;
text-align: center;
padding-top: 2px;
background: var(--ctms-neutral-100);
border-radius: 4px;
height: 20px;
line-height: 20px;
}
.detail-line-content {
flex: 1;
min-width: 0;
}
.diff-field {
display: inline-block;
font-size: 12px;
font-weight: 600;
color: var(--ctms-primary);
margin-bottom: 4px;
}
.diff-values {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.diff-old {
font-size: 12px;
color: var(--ctms-danger);
background: #fef2f2;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
max-width: 45%;
text-decoration: line-through;
text-decoration-color: rgba(194, 75, 75, 0.3);
}
.diff-arrow {
color: var(--ctms-text-disabled);
flex-shrink: 0;
margin-top: 3px;
}
.diff-new {
font-size: 12px;
color: var(--ctms-success);
background: #f0fdf4;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
max-width: 45%;
}
.detail-line-text {
color: var(--ctms-text-regular);
word-break: break-word;
font-size: 13px;
line-height: 1.5;
}
.empty-diff {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 30px 20px;
}
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-wrap: wrap; }
}
</style>