Files
Scoreboard/web/src/admin/components/system/JsonDiff.vue
T

286 lines
7.5 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.
<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>