历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的 状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次 改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。 合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份 还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的 第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有 备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精 确到秒、同秒保存互相覆盖的隐患。
This commit is contained in:
@@ -173,6 +173,13 @@ export const api = {
|
||||
restoreBackup: (name: string) =>
|
||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||
|
||||
/** 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)。 */
|
||||
backupDiff: (name: string) =>
|
||||
request<{ before: string; after: string }>(
|
||||
'GET',
|
||||
`/api/config/backups/${encodeURIComponent(name)}/diff`,
|
||||
),
|
||||
|
||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
/**
|
||||
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
|
||||
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
|
||||
* 点一行展开这次改动的 diff:改动前是备份本身,改动后是更新一份的状态。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { backupTime } from '@shared/backups.ts'
|
||||
import { diffHunks, diffLines } from '@shared/diff.ts'
|
||||
import type { DiffLine } from '@shared/diff.ts'
|
||||
import { api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
|
||||
@@ -18,12 +22,19 @@ const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const backups = ref<string[] | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
/** 展开着的那条。同时只开一条,diff 是逐条看的东西,摊开一串没法读 */
|
||||
const expanded = ref<string | null>(null)
|
||||
/** 已取回并算好的 diff 片段。null 表示取失败了 */
|
||||
const diffs = ref<Record<string, DiffLine[][] | null>>({})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
dialog.value?.showModal()
|
||||
backups.value = null
|
||||
expanded.value = null
|
||||
diffs.value = {}
|
||||
void api
|
||||
.listBackups()
|
||||
.then((r) => (backups.value = r.backups))
|
||||
@@ -34,6 +45,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
async function toggleDiff(name: string) {
|
||||
expanded.value = expanded.value === name ? null : name
|
||||
if (expanded.value !== name || name in diffs.value) return
|
||||
try {
|
||||
const { before, after } = await api.backupDiff(name)
|
||||
diffs.value = { ...diffs.value, [name]: diffHunks(diffLines(before, after)) }
|
||||
} catch {
|
||||
diffs.value = { ...diffs.value, [name]: null }
|
||||
}
|
||||
}
|
||||
|
||||
const sign = (kind: DiffLine['kind']) => (kind === 'add' ? '+' : kind === 'del' ? '−' : ' ')
|
||||
|
||||
async function restore(name: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
@@ -75,9 +99,35 @@ function onBackdrop(event: MouseEvent) {
|
||||
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
|
||||
</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="name in backups" :key="name" class="item" :title="name">
|
||||
<span class="mono time">{{ backupTime(name) ?? name }}</span>
|
||||
<button type="button" class="btn" :disabled="busy" @click="restore(name)">还原</button>
|
||||
<li v-for="name in backups" :key="name" class="item">
|
||||
<div
|
||||
class="row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="name"
|
||||
@click="toggleDiff(name)"
|
||||
@keydown.enter="toggleDiff(name)"
|
||||
>
|
||||
<IconRight :class="['chev', expanded === name && 'is-open']" aria-hidden="true" />
|
||||
<span class="mono time">{{ backupTime(name) ?? name }}</span>
|
||||
<button type="button" class="btn" :disabled="busy" @click.stop="restore(name)">
|
||||
还原
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded === name" class="diff mono">
|
||||
<p v-if="!(name in diffs)" class="faint">载入中</p>
|
||||
<p v-else-if="diffs[name] === null" class="faint">对比内容读取失败</p>
|
||||
<p v-else-if="diffs[name]!.length === 0" class="faint">和之后的状态没有差异</p>
|
||||
<template v-else>
|
||||
<template v-for="(hunk, hi) in diffs[name]" :key="hi">
|
||||
<div v-if="hi > 0" class="gap" aria-hidden="true">⋯</div>
|
||||
<div v-for="(line, li) in hunk" :key="li" :class="['dl', line.kind]">
|
||||
<span class="dsign">{{ sign(line.kind) }}</span>{{ line.text }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -86,7 +136,7 @@ function onBackdrop(event: MouseEvent) {
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(420px, calc(100vw - 2 * var(--s-5)));
|
||||
width: min(640px, calc(100vw - 2 * var(--s-5)));
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-lg);
|
||||
@@ -140,25 +190,37 @@ function onBackdrop(event: MouseEvent) {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 50vh;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-2) 0;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-faint);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.chev.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: 1;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
@@ -178,4 +240,43 @@ function onBackdrop(event: MouseEvent) {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.diff {
|
||||
margin: 0 0 var(--s-3);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-bg);
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
font-size: var(--t-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dl {
|
||||
white-space: pre;
|
||||
}
|
||||
.dl.add {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
.dl.del {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.dl.same {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.dsign {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gap {
|
||||
padding: var(--s-1) 0;
|
||||
color: var(--c-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user