Files
im0o a40bd6a01b 历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的
状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次
改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。

合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份
还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的
第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有
备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精
确到秒、同秒保存互相覆盖的隐患。
2026-08-12 13:17:11 +08:00

283 lines
7.0 KiB
Vue
Raw Permalink 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">
/**
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
* 点一行展开这次改动的 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 }>()
/** restored:还原成功,外壳该重读配置了。 */
const emit = defineEmits<{ close: []; restored: [] }>()
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))
.catch(() => (backups.value = []))
} else {
dialog.value?.close()
}
},
)
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
try {
const result = await api.restoreBackup(name)
const time = backupTime(name) ?? name
toast(result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`)
emit('restored')
emit('close')
} catch (error) {
toastError(error)
} finally {
busy.value = false
}
}
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
function onBackdrop(event: MouseEvent) {
if (event.target === dialog.value) emit('close')
}
</script>
<template>
<dialog
ref="dialog"
class="dlg"
@click="onBackdrop"
@close="emit('close')"
@cancel.prevent="emit('close')"
>
<div class="body">
<header class="head">
<h2>历史记录</h2>
<button type="button" class="x" title="关闭" @click="emit('close')"><IconClose /></button>
</header>
<p v-if="backups === null" class="faint">载入中</p>
<p v-else-if="backups.length === 0" class="faint">
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
</p>
<ul v-else class="list">
<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>
</dialog>
</template>
<style scoped>
.dlg {
width: min(640px, calc(100vw - 2 * var(--s-5)));
padding: 0;
border: 1px solid var(--c-border-strong);
border-radius: var(--r-lg);
background: var(--c-surface);
color: var(--c-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.45);
}
.body {
padding: var(--s-5);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s-3);
}
.head h2 {
font-size: var(--t-lg);
font-weight: 600;
}
.x {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.x:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.faint {
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.list {
list-style: none;
margin: 0;
padding: 0;
max-height: 60vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
}
.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);
}
.btn {
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.btn:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.btn:disabled {
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>