feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user