feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入

This commit is contained in:
AskaEth committed 2026-09-09 19:51:50 +08:00
1 parent f3813171d3
commit 72462792ec
39 files changed
+10001 -28

No files matched your search

@@ -0,0 +1,285 @@
<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>