1227 lines
39 KiB
Vue
1227 lines
39 KiB
Vue
<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">{{ uniqueIpCount }}</span>
|
||
<span class="stat-label">来源 IP</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="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" />
|
||
</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.clientType" clearable :placeholder="TEXT.modules.adminAuditLogs.filterSource" @change="onServerFilterChange" class="filter-select-source">
|
||
<el-option v-for="opt in sourceTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
</div>
|
||
<div class="filter-item-form">
|
||
<el-input
|
||
v-model="filters.ipKeyword"
|
||
clearable
|
||
:placeholder="TEXT.modules.adminAuditLogs.filterIpLocation"
|
||
class="filter-input-ip"
|
||
@input="onLocalFilterChange"
|
||
@clear="onServerFilterChange"
|
||
@change="onServerFilterChange"
|
||
/>
|
||
</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="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="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="120">
|
||
<template #default="scope">
|
||
<div class="actor-cell">
|
||
<span class="actor-name">{{ scope.row.actorName }}</span>
|
||
<span v-if="scope.row.actorAccount" class="actor-account">{{ scope.row.actorAccount }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="TEXT.modules.adminAuditLogs.columns.source" width="230">
|
||
<template #default="scope">
|
||
<div class="source-cell">
|
||
<span class="source-main">{{ scope.row.clientSourceLabel }}</span>
|
||
<span class="source-meta">{{ formatClientMeta(scope.row) }}</span>
|
||
<span class="source-ip">{{ formatClientIpLine(scope.row) }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<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 :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">
|
||
<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" 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>
|
||
<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="76" 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="true"
|
||
: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">
|
||
<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">
|
||
<span class="meta-label">动作</span>
|
||
<span class="meta-value">{{ selectedLog.actionText || TEXT.common.fallback }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="detail-meta-grid">
|
||
<div class="meta-card">
|
||
<span class="meta-label">请求来源</span>
|
||
<span class="meta-value">{{ selectedLog.clientSourceLabel || TEXT.common.fallback }}</span>
|
||
</div>
|
||
<div class="meta-card">
|
||
<span class="meta-label">来源 IP</span>
|
||
<span class="meta-value">{{ selectedLog.clientIp || "未知 IP" }}</span>
|
||
</div>
|
||
<div class="meta-card">
|
||
<span class="meta-label">IP 位置</span>
|
||
<span class="meta-value">{{ selectedLog.ipLocation || TEXT.common.fallback }}</span>
|
||
</div>
|
||
<div class="meta-card">
|
||
<span class="meta-label">客户端</span>
|
||
<span class="meta-value">{{ formatClientMeta(selectedLog) }}</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"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 0 20"/><path d="M12 2a15.3 15.3 0 0 0 0 20"/></svg>
|
||
访问上下文
|
||
</div>
|
||
<div class="context-lines">
|
||
<div><span>User-Agent</span><code>{{ selectedLog.userAgent || TEXT.common.fallback }}</code></div>
|
||
<div><span>构建信息</span><code>{{ formatBuildMeta(selectedLog) }}</code></div>
|
||
</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="selectedDiffGroups.length">{{ selectedDiffGroups.length }} 项</span>
|
||
</div>
|
||
<el-scrollbar max-height="55vh">
|
||
<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">
|
||
<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>
|
||
<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 { 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";
|
||
import { auditDict, normalizeAuditEvent } from "../../audit";
|
||
import { useStudyStore } from "../../store/study";
|
||
import { useAuthStore } from "../../store/auth";
|
||
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";
|
||
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);
|
||
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: "",
|
||
clientType: "",
|
||
ipKeyword: "",
|
||
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 uniqueIpCount = computed(() => new Set(allLogs.value.map(l => l.clientIp || "未知 IP")).size);
|
||
|
||
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*->\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,
|
||
}));
|
||
const sourceTypeOptions = [
|
||
{ value: "web", label: "网页端" },
|
||
{ value: "desktop", label: "桌面端" },
|
||
{ value: "unknown", 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 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(() => {
|
||
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"]);
|
||
});
|
||
const canAccessAuditLogs = computed(() => {
|
||
if (isAdmin.value) return true;
|
||
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]);
|
||
});
|
||
|
||
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 (!selectedStudyId.value) return;
|
||
const { data } = await fetchApiEndpointPermissions(selectedStudyId.value);
|
||
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 (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,
|
||
full_name: m.user?.display_name || m.user?.full_name || m.user?.username || m.username,
|
||
}));
|
||
}
|
||
} catch {
|
||
users.value = [];
|
||
}
|
||
};
|
||
|
||
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 isIpSearchToken = (value: string) => /^[0-9a-f:.]+$/i.test(value.trim());
|
||
|
||
const buildAuditServerParams = () => {
|
||
const ipKeyword = String(filters.value.ipKeyword || "").trim();
|
||
return {
|
||
action: filters.value.eventType || undefined,
|
||
operator_id: filters.value.operatorId || undefined,
|
||
client_type: filters.value.clientType || undefined,
|
||
client_ip: ipKeyword && isIpSearchToken(ipKeyword) ? ipKeyword : undefined,
|
||
};
|
||
};
|
||
|
||
const formatClientMeta = (log: any) => {
|
||
const meta = [log.clientType, log.clientVersion, log.clientPlatform].filter(Boolean).join("/");
|
||
if (meta) return meta;
|
||
return log.userAgent ? "未标识客户端" : TEXT.common.fallback;
|
||
};
|
||
|
||
const formatBuildMeta = (log: any) => {
|
||
const meta = [log.buildChannel, log.buildCommit].filter(Boolean).join("/");
|
||
return meta || TEXT.common.fallback;
|
||
};
|
||
|
||
const formatClientIpLine = (log: any) => {
|
||
const parts = [log.clientIp || "未知 IP", log.ipLocation].filter(Boolean);
|
||
return parts.join(" / ");
|
||
};
|
||
|
||
const loadLogs = async () => {
|
||
if (!selectedStudyId.value) return;
|
||
loading.value = true;
|
||
try {
|
||
if (!permissionMatrix.value) {
|
||
await loadPermissionMatrix();
|
||
}
|
||
const allItems = await fetchAuditLogPages(buildAuditServerParams());
|
||
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());
|
||
const byActor = new Map(users.value.map((user) => [user.id, resolveUserDisplayName(user)]));
|
||
allLogs.value.forEach((log) => {
|
||
if (!byActor.has(log.actorId)) {
|
||
byActor.set(log.actorId, log.actorName || log.actorAccount || 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;
|
||
const ipKeyword = String(filters.value.ipKeyword || "").trim().toLowerCase();
|
||
if (ipKeyword) {
|
||
const haystack = [
|
||
log.clientIp,
|
||
log.ipLocation,
|
||
log.ipCountry,
|
||
log.ipProvince,
|
||
log.ipCity,
|
||
log.ipIsp,
|
||
log.clientSourceLabel,
|
||
log.clientType,
|
||
log.clientPlatform,
|
||
log.userAgent,
|
||
log.actorName,
|
||
log.actorAccount,
|
||
];
|
||
if (!haystack.some((value) => String(value || "").toLowerCase().includes(ipKeyword))) 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 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();
|
||
};
|
||
|
||
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 (!selectedStudyId.value) return [];
|
||
exportLoading.value = true;
|
||
try {
|
||
const items = await fetchAuditLogPages(buildAuditServerParams());
|
||
const userMap = users.value.reduce<Record<string, string>>((acc, cur) => {
|
||
acc[cur.id] = resolveUserDisplayName(cur);
|
||
return acc;
|
||
}, {});
|
||
return filterLogs(items.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) => {
|
||
if (command === "project") confirmExport();
|
||
};
|
||
|
||
const confirmExport = async () => {
|
||
const currentStudy = selectedStudy.value;
|
||
if (!currentStudy) return;
|
||
if (!canProjectExport.value) 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 = `${TEXT.modules.adminAuditLogs.exportProjectName}_${currentStudy.code || ""}_${new Date().toISOString().slice(0, 10)}`;
|
||
exportAuditCsv(events, { fileName });
|
||
await createAuditEvent(selectedStudyId.value, {
|
||
action: "AUDIT_EXPORT_PROJECT",
|
||
}).catch(() => null);
|
||
};
|
||
|
||
onMounted(async () => {
|
||
if (!auth.user && auth.token) {
|
||
await auth.fetchMe().catch(() => {});
|
||
}
|
||
await loadAvailableStudies().catch(() => {});
|
||
await loadPermissionMatrix().catch(() => {});
|
||
ensureAccess();
|
||
await loadUsers();
|
||
await loadLogs();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 统计卡片 */
|
||
.stats-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--unified-shell-divider);
|
||
}
|
||
|
||
.stat-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 12px;
|
||
border-radius: 10px;
|
||
background: var(--ctms-neutral-100);
|
||
transition: var(--ctms-transition);
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--ctms-shadow-sm);
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.stat-icon svg { width: 18px; height: 18px; }
|
||
.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: 20px; font-weight: 700; line-height: 1.2; color: var(--ctms-text-main); }
|
||
.stat-label { font-size: 11px; 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: wrap;
|
||
}
|
||
|
||
.filter-item-form {
|
||
margin-bottom: 0 !important;
|
||
margin-right: 0 !important;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.filter-select-comp { width: 132px; }
|
||
.filter-select-project { width: 180px; }
|
||
.filter-select-source { width: 112px; }
|
||
.filter-input-ip { width: 170px; }
|
||
.filter-select-result { width: 96px; }
|
||
.date-range-picker-comp { width: 248px !important; }
|
||
.filter-spacer { flex: 1; min-width: 16px; }
|
||
|
||
.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; }
|
||
.audit-table :deep(.el-table__cell) {
|
||
padding: 8px 0;
|
||
}
|
||
.audit-table :deep(.cell) {
|
||
padding: 0 12px;
|
||
}
|
||
|
||
.time-text {
|
||
font-size: 12px;
|
||
color: var(--ctms-text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.actor-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.actor-name,
|
||
.actor-account {
|
||
display: block;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.actor-name {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.actor-account {
|
||
font-size: 11px;
|
||
color: var(--ctms-text-secondary);
|
||
}
|
||
|
||
.source-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.source-main {
|
||
display: inline-flex;
|
||
align-self: flex-start;
|
||
max-width: 100%;
|
||
padding: 1px 7px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #155e75;
|
||
background: #ecfeff;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.source-meta,
|
||
.source-ip {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: var(--ctms-text-secondary);
|
||
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;
|
||
}
|
||
|
||
.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 {
|
||
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;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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-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);
|
||
}
|
||
|
||
.context-lines {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.context-lines div {
|
||
display: grid;
|
||
grid-template-columns: 92px minmax(0, 1fr);
|
||
gap: 10px;
|
||
align-items: start;
|
||
}
|
||
|
||
.context-lines span {
|
||
color: var(--ctms-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.context-lines code {
|
||
display: block;
|
||
padding: 8px 10px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--ctms-border-color);
|
||
background: var(--ctms-neutral-100);
|
||
color: var(--ctms-text-main);
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.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-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-text-regular);
|
||
line-height: 1.8;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.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; }
|
||
.diff-item-row { grid-template-columns: 1fr; gap: 4px; }
|
||
}
|
||
</style>
|