286 lines
7.5 KiB
Vue
286 lines
7.5 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
|
||
/**
|
||
* 审计日志的 before / after 对比。
|
||
*
|
||
* 库里存的是 JSON 文本,字段顺序和缩进都不稳定,逐行 diff 会满屏噪声;
|
||
* 所以先解析成「路径 → 值」再按路径比对,一行一个字段,改了什么一眼可见。
|
||
* 解析失败(历史脏数据)时退回原文对照,不能因为一条坏记录就整页报错。
|
||
*/
|
||
const props = defineProps<{ before: string | null; after: string | null }>()
|
||
|
||
type ChangeKind = 'added' | 'removed' | 'modified' | 'unchanged'
|
||
|
||
interface DiffRow {
|
||
path: string
|
||
before: string
|
||
after: string
|
||
change: ChangeKind
|
||
}
|
||
|
||
const showUnchanged = ref(false)
|
||
|
||
interface Parsed {
|
||
ok: boolean
|
||
value: unknown
|
||
}
|
||
|
||
/** 空文本按 null 处理(新增/删除操作的另一侧就是空的),只有非法 JSON 才算解析失败。 */
|
||
function parseJson(text: string | null): Parsed {
|
||
if (text === null || text.trim() === '') return { ok: true, value: null }
|
||
try {
|
||
return { ok: true, value: JSON.parse(text) as unknown }
|
||
} catch {
|
||
return { ok: false, value: null }
|
||
}
|
||
}
|
||
|
||
const parsed = computed(() => ({
|
||
before: parseJson(props.before),
|
||
after: parseJson(props.after),
|
||
}))
|
||
|
||
const parseFailed = computed(() => !parsed.value.before.ok || !parsed.value.after.ok)
|
||
|
||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
function formatLeaf(value: unknown): string {
|
||
if (value === null) return 'null'
|
||
if (typeof value === 'string') return value
|
||
return JSON.stringify(value)
|
||
}
|
||
|
||
function flatten(value: unknown, prefix: string, out: Map<string, string>): void {
|
||
if (Array.isArray(value)) {
|
||
if (value.length === 0) {
|
||
out.set(prefix === '' ? '(根)' : prefix, '[]')
|
||
return
|
||
}
|
||
value.forEach((item, index) => flatten(item, `${prefix}[${index}]`, out))
|
||
return
|
||
}
|
||
if (isPlainRecord(value)) {
|
||
const keys = Object.keys(value)
|
||
if (keys.length === 0) {
|
||
out.set(prefix === '' ? '(根)' : prefix, '{}')
|
||
return
|
||
}
|
||
for (const key of keys) {
|
||
flatten(value[key], prefix === '' ? key : `${prefix}.${key}`, out)
|
||
}
|
||
return
|
||
}
|
||
out.set(prefix === '' ? '(根)' : prefix, formatLeaf(value))
|
||
}
|
||
|
||
const rows = computed<DiffRow[]>(() => {
|
||
const { before, after } = parsed.value
|
||
if (!before.ok || !after.ok) return []
|
||
|
||
const beforeMap = new Map<string, string>()
|
||
const afterMap = new Map<string, string>()
|
||
flatten(before.value, '', beforeMap)
|
||
flatten(after.value, '', afterMap)
|
||
|
||
const paths = [...new Set([...beforeMap.keys(), ...afterMap.keys()])].sort((a, b) =>
|
||
a.localeCompare(b),
|
||
)
|
||
|
||
return paths.map((path) => {
|
||
const hasBefore = beforeMap.has(path)
|
||
const hasAfter = afterMap.has(path)
|
||
const beforeText = beforeMap.get(path) ?? '—'
|
||
const afterText = afterMap.get(path) ?? '—'
|
||
let change: ChangeKind = 'unchanged'
|
||
if (!hasBefore) change = 'added'
|
||
else if (!hasAfter) change = 'removed'
|
||
else if (beforeText !== afterText) change = 'modified'
|
||
return { path, before: beforeText, after: afterText, change }
|
||
})
|
||
})
|
||
|
||
const visibleRows = computed(() =>
|
||
showUnchanged.value ? rows.value : rows.value.filter((row) => row.change !== 'unchanged'),
|
||
)
|
||
|
||
const changedCount = computed(() => rows.value.filter((row) => row.change !== 'unchanged').length)
|
||
|
||
const CHANGE_LABEL: Record<ChangeKind, string> = {
|
||
added: '新增',
|
||
removed: '删除',
|
||
modified: '修改',
|
||
unchanged: '未变',
|
||
}
|
||
|
||
const CHANGE_TONE: Record<ChangeKind, string> = {
|
||
added: 'mx-badge-success',
|
||
removed: 'mx-badge-error',
|
||
modified: 'mx-badge-warning',
|
||
unchanged: 'mx-badge-neutral',
|
||
}
|
||
|
||
function pretty(text: string | null): string {
|
||
const result = parseJson(text)
|
||
if (!result.ok) return text ?? '—'
|
||
return JSON.stringify(result.value, null, 2)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="json-diff">
|
||
<div class="diff-head">
|
||
<span class="mx-badge" :class="changedCount > 0 ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||
{{ changedCount > 0 ? `${changedCount} 处变化` : '无变化' }}
|
||
</span>
|
||
<label class="toggle">
|
||
<input v-model="showUnchanged" type="checkbox" />
|
||
显示未变化字段
|
||
</label>
|
||
</div>
|
||
|
||
<p v-if="parseFailed" class="fallback-note">
|
||
before / after 不是合法 JSON,下面按原文对照显示。
|
||
</p>
|
||
|
||
<div v-if="parseFailed" class="raw-grid">
|
||
<div class="raw-col">
|
||
<div class="raw-title">变更前</div>
|
||
<pre class="raw mono">{{ before ?? '—' }}</pre>
|
||
</div>
|
||
<div class="raw-col">
|
||
<div class="raw-title">变更后</div>
|
||
<pre class="raw mono">{{ after ?? '—' }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div v-if="visibleRows.length === 0" class="mx-empty">
|
||
<div class="mx-empty-title">字段无变化</div>
|
||
<div class="mx-empty-desc">该操作只影响记录状态,未修改任何字段值</div>
|
||
</div>
|
||
<div v-else class="mx-table-wrap">
|
||
<table class="mx-table mx-table-compact">
|
||
<thead>
|
||
<tr>
|
||
<th>字段</th>
|
||
<th>变更前</th>
|
||
<th>变更后</th>
|
||
<th>类型</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in visibleRows" :key="row.path">
|
||
<td class="path mono">{{ row.path }}</td>
|
||
<td class="value mono before">{{ row.before }}</td>
|
||
<td class="value mono after">{{ row.after }}</td>
|
||
<td>
|
||
<span class="mx-badge" :class="CHANGE_TONE[row.change]">
|
||
{{ CHANGE_LABEL[row.change] }}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<details class="raw-details">
|
||
<summary>查看原始 JSON</summary>
|
||
<div class="raw-grid">
|
||
<div class="raw-col">
|
||
<div class="raw-title">变更前</div>
|
||
<pre class="raw mono">{{ pretty(before) }}</pre>
|
||
</div>
|
||
<div class="raw-col">
|
||
<div class="raw-title">变更后</div>
|
||
<pre class="raw mono">{{ pretty(after) }}</pre>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.json-diff {
|
||
padding: 12px 14px;
|
||
background: var(--mx-s2);
|
||
border-radius: var(--mx-r-sm);
|
||
}
|
||
.diff-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
cursor: pointer;
|
||
}
|
||
.path {
|
||
font-size: 12px;
|
||
color: var(--mx-t2);
|
||
word-break: break-all;
|
||
}
|
||
.value {
|
||
font-size: 12px;
|
||
max-width: 320px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.before {
|
||
color: var(--mx-t3);
|
||
}
|
||
.after {
|
||
color: var(--mx-green);
|
||
}
|
||
.fallback-note {
|
||
font-size: 12px;
|
||
color: var(--mx-amber);
|
||
margin-bottom: 8px;
|
||
}
|
||
.raw-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
.raw-title {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
margin-bottom: 4px;
|
||
}
|
||
.raw {
|
||
margin: 0;
|
||
padding: 10px;
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
background: var(--mx-s3);
|
||
border-radius: var(--mx-r-xs);
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
.raw-details summary {
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
cursor: pointer;
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.raw-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|