feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复

用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
This commit is contained in:
AskaEth committed 2026-09-11 19:26:36 +08:00
1 parent 631c8dd8e1
commit 53a0cb225c
61 files changed
+6386 -699

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Team, TeamInput } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
@@ -7,10 +7,20 @@ import ImageUploadField from '../system/ImageUploadField.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useAuthStore } from '../../stores/auth'
import { adminApi } from '../../api/client'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
const auth = useAuthStore()
/**
* 「已彻底删除」的单位默认不进列表,只有 admin 能用开关把它们翻出来恢复:
* `GET /api/teams?includeDeleted=1` 服务端只放 admin(其余角色 403),
* 所以这个开关对非 admin 既不显示也不生效。
*/
const isAdmin = computed(() => auth.user?.role === 'admin')
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,16}$/
const COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
@@ -47,22 +57,64 @@ const purgePending = ref(false)
/** 删除失败时的说明留在页面上:服务端那句原文(单位已不在等)比一行「操作失败」有用得多。 */
const purgeError = ref<string | null>(null)
/** 已彻底删除的单位:只在勾选「显示已彻底删除的单位」后单独拉取,不混进共享的 teams 列表。 */
const showDeleted = ref(false)
const hiddenTeams = ref<Team[]>([])
const hiddenLoading = ref(false)
const hiddenError = ref<string | null>(null)
const restoreOpen = ref(false)
const restoreTarget = ref<Team | null>(null)
const restorePending = ref(false)
const restoreError = ref<string | null>(null)
function emptyForm(): TeamForm {
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
}
/** 关键字匹配(代码 / 全称 / 简称),可见与已删除的行用同一套。 */
function matchesKeyword(team: Team, keyword: string): boolean {
if (keyword === '') return true
return (
team.code.toLowerCase().includes(keyword) ||
team.name.toLowerCase().includes(keyword) ||
(team.shortName ?? '').toLowerCase().includes(keyword)
)
}
const filtered = computed(() => {
const keyword = search.value.trim().toLowerCase()
return data.teams.value.filter((team) => {
const visible = data.teams.value.filter((team) => {
if (statusFilter.value === 'active' && !team.active) return false
if (statusFilter.value === 'inactive' && team.active) return false
if (keyword === '') return true
return (
team.code.toLowerCase().includes(keyword) ||
team.name.toLowerCase().includes(keyword) ||
(team.shortName ?? '').toLowerCase().includes(keyword)
)
return matchesKeyword(team, keyword)
})
// 已彻底删除的单位一律 active=0,按状态筛选会永远被滤掉,所以它们只跟搜索走。
return [...visible, ...hiddenTeams.value.filter((team) => matchesKeyword(team, keyword))]
})
async function loadHiddenTeams(): Promise<void> {
hiddenLoading.value = true
hiddenError.value = null
try {
const rows = await adminApi.client.teams.list({ includeDeleted: true })
hiddenTeams.value = rows.filter((team) => team.deletedAt != null)
} catch (caught) {
hiddenTeams.value = []
hiddenError.value = caught instanceof Error ? caught.message : '已彻底删除的单位加载失败'
} finally {
hiddenLoading.value = false
}
}
// 关掉开关就清空:留着上一份已删除名单,下一次打开会先闪一遍过期数据。
watch(showDeleted, (on) => {
if (!isAdmin.value) {
showDeleted.value = false
return
}
if (on) void loadHiddenTeams()
else hiddenTeams.value = []
})
const activeCount = computed(() => data.teams.value.filter((team) => team.active).length)
@@ -166,6 +218,25 @@ async function toggleActive(team: Team): Promise<void> {
}
}
/**
* 彻底删除确认框的说明。恢复入口藏在「显示已彻底删除的单位」开关后面,而那个开关
* 对非 admin 只显示不生效(服务端 403),所以必须在按下删除前把恢复要找管理员说清楚,
* 否则录入员删完在本机找不到任何恢复入口。admin 自己能恢复,不需要这句。
*/
const purgeDetails = computed(() =>
isAdmin.value
? [
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
'管理员可以勾选「显示已彻底删除的单位」,再从列表里把它恢复回来',
'只是想暂时不让它参加,请用「停用」',
]
: [
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
'彻底删除后本账号无法自行恢复,需要管理员在「显示已彻底删除的单位」里恢复',
'只是想暂时不让它参加,请用「停用」',
],
)
function askPurge(team: Team): void {
purgeTarget.value = team
purgeError.value = null
@@ -182,6 +253,8 @@ async function confirmPurge(): Promise<void> {
await data.purgeTeam(target.id)
toast.success(`已彻底删除「${target.name}」`)
purgeOpen.value = false
// 名单里若有这份已删除数据(开关开着),跟着刷新,行不会被留在表里当活单位看。
if (showDeleted.value) await loadHiddenTeams()
} catch (caught) {
// 服务端的 message(单位已不在、网络错误…)必须原文展示,不能压成「删除失败」。
const message = caught instanceof Error ? caught.message : '彻底删除失败'
@@ -192,6 +265,37 @@ async function confirmPurge(): Promise<void> {
purgePending.value = false
}
}
function askRestore(team: Team): void {
restoreTarget.value = team
restoreError.value = null
restoreOpen.value = true
}
async function confirmRestore(): Promise<void> {
const target = restoreTarget.value
if (target === null || restorePending.value) return
restorePending.value = true
restoreError.value = null
try {
const result = await adminApi.client.teams.restore(target.id)
// restored=false 是幂等命中(别人已经恢复过、或它本来就没被隐藏):事实是「它现在是可见的」,
// 提示照实说,不谎报成这次恢复了它。
toast.success(result.restored ? `已恢复「${target.name}」` : `「${target.name}」本就未被彻底删除`)
restoreOpen.value = false
// 两处列表都要刷新:单位回到默认列表,已删除名单里那一行消失。
await data.load(true)
if (showDeleted.value) await loadHiddenTeams()
} catch (caught) {
// 服务端那句原文(单位不存在、编码被占用…)要留在页面上,别压成一句「恢复失败」。
const message = caught instanceof Error ? caught.message : '恢复失败'
restoreError.value = message
toast.error('恢复失败', message)
restoreOpen.value = false
} finally {
restorePending.value = false
}
}
</script>
<template>
@@ -199,6 +303,9 @@ async function confirmPurge(): Promise<void> {
<div class="mx-card-head">
<span class="mx-card-title">参赛单位</span>
<span class="mx-badge mx-badge-neutral">{{ activeCount }} / {{ data.teams.value.length }} 启用</span>
<span v-if="hiddenTeams.length > 0" class="mx-badge mx-badge-error">
另有 {{ hiddenTeams.length }} 个已彻底删除
</span>
</div>
<div class="mx-card-body">
@@ -213,6 +320,11 @@ async function confirmPurge(): Promise<void> {
<option value="active">仅启用</option>
<option value="inactive">仅停用</option>
</select>
<label v-if="isAdmin" class="bd-check bd-show-deleted" title="只有管理员能查看并恢复被彻底删除的单位">
<input v-model="showDeleted" type="checkbox" />
显示已彻底删除的单位
</label>
<span v-if="hiddenLoading" class="mx-spinner" />
</div>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-primary" type="button" :disabled="!data.canWrite.value" @click="openCreate">
@@ -224,6 +336,12 @@ async function confirmPurge(): Promise<void> {
<div v-if="purgeError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ purgeError }}
</div>
<div v-if="hiddenError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> 已彻底删除的单位加载失败:{{ hiddenError }}
</div>
<div v-if="restoreError !== null" class="mx-badge mx-badge-error purge-error">
<MxIcon name="warning" :size="14" /> {{ restoreError }}
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
@@ -258,12 +376,24 @@ async function confirmPurge(): Promise<void> {
</td>
<td class="right bd-mono">{{ team.sortOrder }}</td>
<td>
<span class="mx-badge" :class="team.active ? 'mx-badge-success' : 'mx-badge-neutral'">
<span v-if="team.deletedAt != null" class="mx-badge mx-badge-error">已彻底删除</span>
<span v-else class="mx-badge" :class="team.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ team.active ? '启用' : '已停用' }}
</span>
</td>
<td>
<div class="bd-actions">
<!-- 已彻底删除的行只留「恢复」:编辑 / 停用 / 再删一次对不可见的单位都没有意义。 -->
<div v-if="team.deletedAt != null" class="bd-actions">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || restorePending"
@click="askRestore(team)"
>
{{ restorePending && restoreTarget?.id === team.id ? '恢复中…' : '恢复' }}
</button>
</div>
<div v-else class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openEdit(team)">
编辑
</button>
@@ -300,7 +430,7 @@ async function confirmPurge(): Promise<void> {
</table>
</div>
<p class="bd-note">
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,界面上不可恢复,但它的报名、成绩与积分数据全部保留,积分榜不会变化。
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,它的报名、成绩与积分数据全部保留、积分榜不变;管理员勾选「显示已彻底删除的单位」后可以「恢复」它,恢复不改动任何成绩数据。
</p>
</div>
@@ -359,16 +489,28 @@ async function confirmPurge(): Promise<void> {
:open="purgeOpen"
tone="danger"
title="彻底删除该参赛单位?"
:message="`将把「${purgeTarget?.name ?? ''}」从参赛单位列表中移除,之后不再出现在单位列表与报名选择里,界面上无法恢复。`"
:details="[
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
'只是想暂时不让它参加,请用「停用」',
]"
:message="`将把「${purgeTarget?.name ?? ''}」从参赛单位列表中移除,之后不再出现在单位列表与报名选择里。`"
:details="purgeDetails"
confirm-text="彻底删除"
:pending="purgePending"
@confirm="confirmPurge"
@cancel="purgeOpen = false"
/>
<ConfirmDialog
:open="restoreOpen"
tone="info"
title="恢复该参赛单位?"
:message="`「${restoreTarget?.name ?? ''}」将重新出现在单位列表与报名选择里,并恢复为「启用」。`"
:details="[
'彻底删除期间它的报名、成绩与积分一直保留着,所以恢复不会改榜、也不会重算',
'它先前的简称、主题色与排序原样恢复,单位编码不会重复',
]"
confirm-text="恢复该单位"
:pending="restorePending"
@confirm="confirmRestore"
@cancel="restoreOpen = false"
/>
</section>
</template>
@@ -381,6 +523,10 @@ async function confirmPurge(): Promise<void> {
.danger-text {
color: var(--mx-red);
}
.bd-show-deleted {
white-space: nowrap;
font-size: 13px;
}
.purge-error {
display: flex;
align-items: flex-start;
+158 -17
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import type { BackupRestoreResult } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import MockBadge from './MockBadge.vue'
import { backupsApi, describeError, type BackupItem } from './systemApi'
import { backupsApi, describeError, waitForServerOnline, type BackupItem } from './systemApi'
import { adminApi } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -11,8 +14,8 @@ import { useToastStore } from '../../stores/toast'
* 备份(devdocs/08 第 6、8 节)。
*
* 自动备份每 5 分钟一次、发布前再备一次,滚动保留 20 份;
* 但服务端目前只提供「列出 + 立即备份」,没有恢复接口,
* 所以恢复只能按流程手工执行,页面把它写成可照做的步骤而不是假装有按钮。
* 恢复是停机动作:服务端校验备份、留一份恢复前安全副本、换掉库文件,然后优雅退出、
* 由服务管理窗口重新拉起。所以恢复按钮只给管理员,并且必须先过危险确认框。
*/
const auth = useAuthStore()
@@ -24,6 +27,12 @@ const loadError = ref<string | null>(null)
const usingMock = ref(false)
const backingUp = ref(false)
const restoreTarget = ref<BackupItem | null>(null)
const restoring = ref(false)
const restarting = ref(false)
const restoreError = ref<string | null>(null)
const lastRestore = ref<BackupRestoreResult | null>(null)
const isAdmin = computed(() => auth.user?.role === 'admin')
const latest = computed<BackupItem | null>(() => rows.value[0] ?? null)
@@ -67,6 +76,103 @@ function reasonLabel(reason: string): string {
return REASON_LABEL[reason] ?? (reason === '' ? '—' : reason)
}
/**
* 库名与「能不能恢复」都取自服务端给的 kind / dbKind(不再从文件名前缀猜):
* kind 是这份文件的性质(系统库 / 赛事库 / 恢复前安全副本),
* dbKind 是它对应的库——恢复请求要传的就是它。
*/
const DB_KIND_LABEL: Record<string, string> = {
system: '系统库',
event: '赛事库',
}
function kindLabelOf(row: BackupItem): string {
const db = DB_KIND_LABEL[row.dbKind] ?? '未识别'
return row.kind === 'safety' ? `${db}·恢复前副本` : db
}
/** 安全副本的「来源」列显示成「恢复前副本」,普通备份仍显示触发原因。 */
function reasonOf(row: BackupItem): string {
return row.kind === 'safety' ? '恢复前副本' : reasonLabel(row.reason)
}
function canRestore(row: BackupItem): boolean {
return isAdmin.value && !usingMock.value && row.dbKind !== ''
}
/** 要覆盖的那份库,用中文写在确认框里,避免「恢复」两个字被当成无损操作。 */
const restoreScope = computed(() => {
const row = restoreTarget.value
if (row === null) return '当前库'
if (row.dbKind === 'system') return '当前系统库 system.db(账号与会话)'
if (row.dbKind === 'event') return '当前赛事库 event.db(赛程、报名与成绩)'
return '当前库'
})
const restoreMessage = computed(() =>
restoreTarget.value === null ? '' : `将用「${restoreTarget.value.name}」覆盖${restoreScope.value}。`,
)
const restoreDetails = computed(() => {
const row = restoreTarget.value
const lines = [
'覆盖之后,备份时间点之后的改动都会消失(自动备份间隔 5 分钟)。',
'服务端会先留一份恢复前安全副本(每类库保留最近 5 份),回执里会给出文件名,选错时间点还能用它退回。',
'恢复期间服务端会优雅退出并由服务管理窗口重新拉起,页面会自动等待并刷新,请勿关闭服务管理窗口。',
]
// 赛事库备份的文件名里不含项目名,从别的项目备下来的长得一模一样。
if (row !== null && row.dbKind === 'event') {
lines.push('赛事库备份的文件名里不含来源项目,恢复前请确认这份备份确实来自当前项目。')
}
if (row !== null && row.dbKind === 'system') {
lines.push('恢复系统库会连同账号与会话一起换掉,重启后需要用该时间点的账号重新登录。')
}
return lines
})
async function confirmRestore(): Promise<void> {
const row = restoreTarget.value
if (row === null || row.dbKind === '') return
restoring.value = true
restoreError.value = null
try {
// kind 传 dbKind:安全副本也属于某一份库,拿它退回上一个时间点是正当操作。
const result = await adminApi.client.system.restore({ file: row.name, kind: row.dbKind })
lastRestore.value = result
restoreTarget.value = null
if (result.warning !== undefined && result.warning !== '') {
toast.warning('恢复已完成,但有一处异常', result.warning)
}
if (!result.restarting) {
// 服务端没接入优雅重启:文件已经换好,下次重启才生效,不假装在重启。
toast.success('已恢复', result.message)
window.location.reload()
return
}
restarting.value = true
toast.info('正在恢复并重启服务端…', `安全副本:${result.safetyCopy}`)
const online = await waitForServerOnline({
probe: () => adminApi.client.health(),
waitForDown: true,
})
if (online) {
toast.success('服务端已恢复,正在刷新页面')
window.location.reload()
return
}
restarting.value = false
restoreError.value = `等待服务端重启超时(60 秒)。恢复已完成(安全副本:${result.safetyCopy}),请打开服务管理窗口确认服务端状态后手动刷新本页。`
toast.error('服务端重启未在 60 秒内完成', '请在服务管理窗口查看服务端状态')
void load()
} catch (caught) {
// 失败一律发生在动库文件之前(或已自动回滚),所以这里可以直说「当前库没动」。
restoreError.value = describeError(caught)
toast.error('恢复未执行,当前库未被覆盖', restoreError.value)
} finally {
restoring.value = false
}
}
function formatSize(bytes: number): string {
if (bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
@@ -103,6 +209,14 @@ onMounted(() => {
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">只有管理员能查看与创建备份。</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<p v-if="restarting" class="notice">
<span class="mx-activity-dot"></span> 服务端正在重启以应用恢复,请勿关闭服务管理窗口…
</p>
<p v-if="restoreError !== null" class="notice error">{{ restoreError }}</p>
<p v-if="lastRestore !== null" class="notice">
最近一次恢复:<code class="mono">{{ lastRestore.file }}</code> → {{ lastRestore.kindLabel }};
恢复前安全副本 <code class="mono">{{ lastRestore.safetyCopy }}</code>(不参与滚动清理,可再恢复一次退回)。
</p>
<p v-if="latest !== null" class="latest">
最近一次备份:{{ formatDateTime(latest.createdAt) }}
<span class="muted">({{ reasonLabel(latest.reason) }})</span>
@@ -120,21 +234,34 @@ onMounted(() => {
<thead>
<tr>
<th>文件名</th>
<th>来源</th>
<th>库 / 来源</th>
<th class="right">大小</th>
<th>创建时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.name">
<td class="mono name">{{ row.name }}</td>
<td>
<span class="mx-badge" :class="row.reason === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ reasonLabel(row.reason) }}
<span class="mx-badge" :class="row.dbKind === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ kindLabelOf(row) }}
</span>
<span class="mx-badge mx-badge-neutral">{{ reasonOf(row) }}</span>
</td>
<td class="right mono">{{ formatSize(row.size) }}</td>
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canRestore(row) || restoring || restarting"
:title="canRestore(row) ? '用这份备份覆盖当前库' : '管理员且非示例数据模式下才能恢复'"
@click="restoreTarget = row"
>
<MxIcon name="restore" :size="14" /> 恢复
</button>
</td>
</tr>
</tbody>
</table>
@@ -146,32 +273,46 @@ onMounted(() => {
<div class="mx-card-head"><span class="mx-card-title">恢复说明</span></div>
<div class="mx-card-body">
<p class="notice">
备份文件存放在 <code class="mono">data/backups/</code>,滚动保留最近 20 份,不随 git 提交。
服务端暂未提供一键恢复接口,恢复需在停机状态下手工执行:
备份文件存放在 <code class="mono">data/backups/</code>:普通备份(系统库 / 赛事库)滚动保留最近 20 份,
恢复前安全副本每类库单独保留最近 5 份,都不随 git 提交。上表每行的「恢复」会用那份备份覆盖当前库:
</p>
<ol class="steps">
<li>
<strong>停服务</strong>
<span>先停掉后端进程(systemd:<code class="mono">systemctl stop scoreboard</code>),避免恢复过程中继续写入。</span>
<strong>先校验,再动手</strong>
<span>服务端先检查备份能不能打开、关键表在不在;坏文件或选错库直接拒绝,当前库不会被覆盖。</span>
</li>
<li>
<strong>留存现场</strong>
<span>把当前的 <code class="mono">event.db</code> 改名备份,确认恢复失败还能退回。</span>
<strong>先留现场</strong>
<span>动手前给当前库拍一份「恢复前安全副本」(<code class="mono">pre-restore-…db</code>),回执里给出文件名;它不参与 20 份滚动清理,可以再拿来恢复一次退回。</span>
</li>
<li>
<strong>替换数据库</strong>
<span>选一份备份文件复制为 <code class="mono">event.db</code>,同时删除 <code class="mono">event.db-wal</code> 与 <code class="mono">event.db-shm</code>。</span>
<strong>换文件并重启</strong>
<span>断开目标库的连接后整文件替换,服务端随之优雅退出、由服务管理窗口重新拉起;赛事库与系统库互不影响。</span>
</li>
<li>
<strong>重启并核对</strong>
<span>启动服务后检查积分榜与最近一次发布记录,确认数据时间点符合预期。</span>
<span>服务端回来后检查积分榜与最近一次发布记录,确认时间点符合预期;不对就用安全副本再恢复一次。</span>
</li>
</ol>
<p class="notice warn">
恢复会丢弃备份时间点之后的所有改动(自动备份间隔 5 分钟)。现场先改 IP 切换备机、再考虑恢复,
通常比原地恢复更快。
恢复会丢弃备份时间点之后的所有改动(自动备份间隔 5 分钟)。
<strong>赛事库备份的文件名里不含来源项目</strong>,从别的赛事项目备下来的
<code class="mono">event-…db</code> 长得一模一样,恢复前请务必确认来源,别盖错项目。
现场先改 IP 切换备机、再考虑恢复,通常比原地恢复更快。
</p>
</div>
<ConfirmDialog
:open="restoreTarget !== null"
tone="danger"
title="用这份备份覆盖当前数据?"
:message="restoreMessage"
:details="restoreDetails"
confirm-text="恢复并重启"
:pending="restoring"
@confirm="confirmRestore"
@cancel="restoreTarget = null"
/>
</section>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime, resolveAssetUrl } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import {
describeError,
imageAssetsApi,
type OrphanDeleteFailure,
type OrphanImage,
} from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
/**
* 图片资源(孤儿清理)。
*
* 换图只改配置指向,旧文件会一直留在服务端的 `uploads/` 下,久了就是一堆谁也认不出的
* 随机名文件。这里列出「没有被任何配置引用」的图片,勾选后删除。
*
* 判定与删除都在服务端:引用点(赛事设置的三处 + 各单位图标)在删除前会再查一遍,
* 期间刚被配上去的图会被拒删并给中文原因——前端这份清单只负责展示,不是权限依据。
* 删的是文件,不可撤销,所以单张与批量都要二次确认。
*/
const auth = useAuthStore()
const toast = useToastStore()
const rows = ref<OrphanImage[]>([])
const totalBytes = ref(0)
const selected = ref<string[]>([])
const loading = ref(false)
const loadError = ref<string | null>(null)
const busy = ref(false)
const failures = ref<OrphanDeleteFailure[]>([])
/** 待确认的删除:batch 为 false 时是行内单张删除。 */
const pending = ref<{ names: string[]; batch: boolean } | null>(null)
const isAdmin = computed(() => auth.user?.role === 'admin')
const allSelected = computed(() => rows.value.length > 0 && selected.value.length === rows.value.length)
async function load(): Promise<void> {
loading.value = true
loadError.value = null
try {
const list = await imageAssetsApi.listOrphans()
rows.value = list.items
totalBytes.value = list.totalBytes
// 清单变了,之前勾中的文件可能已经不在列表里,勾选一并清空。
selected.value = []
} catch (caught) {
loadError.value = describeError(caught)
} finally {
loading.value = false
}
}
function toggleAll(): void {
selected.value = allSelected.value ? [] : rows.value.map((row) => row.name)
}
function toggle(name: string): void {
selected.value = selected.value.includes(name)
? selected.value.filter((item) => item !== name)
: [...selected.value, name]
}
function askDelete(names: string[], batch: boolean): void {
if (names.length === 0) return
pending.value = { names, batch }
}
async function confirmDelete(): Promise<void> {
const task = pending.value
if (task === null) return
busy.value = true
failures.value = []
try {
if (task.batch) {
const result = await imageAssetsApi.removeOrphans(task.names)
failures.value = result.failed
if (result.deleted.length > 0) toast.success('已删除孤儿图片', `共 ${result.deleted.length} 张`)
if (result.failed.length > 0) toast.error('部分图片未删除', `${result.failed.length} 张被跳过,原因见下方`)
} else {
const name = task.names[0] ?? ''
await imageAssetsApi.remove(name)
toast.success('图片已删除', name)
}
pending.value = null
} catch (caught) {
// 单张删除最可能撞上 409(刚被配置引用),服务端的中文原因直接给操作员看。
toast.error('图片删除失败', describeError(caught))
pending.value = null
} finally {
busy.value = false
await load()
}
}
/** 确认框里最多列 10 个文件名,剩下的只报数量,免得弹窗被长清单撑爆。 */
const confirmDetails = computed(() => {
const task = pending.value
if (task === null) return []
const head = task.names.slice(0, 10)
const rest = task.names.length - head.length
return rest > 0 ? [...head, `…以及其余 ${rest} 个文件`] : head
})
function formatSize(bytes: number): string {
if (bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
onMounted(() => {
void load()
})
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">孤儿图片</span>
<div class="head-actions">
<span v-if="!loading" class="summary">
{{ rows.length }} 张 · {{ formatSize(totalBytes) }}
</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="14" /> 刷新
</button>
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!isAdmin || busy || selected.length === 0"
@click="askDelete(selected, true)"
>
<MxIcon name="close" :size="14" /> 删除选中({{ selected.length }})
</button>
</div>
</div>
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">只有管理员能查看与清理上传图片。</p>
<p class="notice">
这里只列「没有被任何配置引用」的图片:赛事会标、大屏会标、大屏背景图,
以及任何一个参赛单位的单位图标都算引用(被彻底删除的单位也算,它可以恢复)。
删除不可撤销;若某张图在列出来之后刚被配到别处,服务端会拒删并说明被谁引用。
</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<div v-if="failures.length > 0" class="notice error">
<div>以下 {{ failures.length }} 张未删除:</div>
<ul class="failure-list">
<li v-for="item in failures" :key="item.name">
<code class="mono">{{ item.name }}</code> —— {{ item.reason }}
</li>
</ul>
</div>
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在扫描 uploads 目录…</div>
<div v-else-if="rows.length === 0" class="mx-empty">
<div class="mx-empty-title">没有孤儿图片</div>
<div class="mx-empty-desc">uploads 目录里的每一张图片都还被配置引用着,没有可以清理的文件</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="check-col">
<input
type="checkbox"
:checked="allSelected"
:disabled="!isAdmin"
aria-label="全选"
@change="toggleAll"
/>
</th>
<th>预览</th>
<th>文件名</th>
<th class="right">大小</th>
<th>修改时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.name">
<td class="check-col">
<input
type="checkbox"
:checked="selected.includes(row.name)"
:disabled="!isAdmin"
:aria-label="`选择 ${row.name}`"
@change="toggle(row.name)"
/>
</td>
<td>
<img class="thumb" :src="resolveAssetUrl(row.url)" alt="" loading="lazy" />
</td>
<td class="mono name">{{ row.name }}</td>
<td class="right mono">{{ formatSize(row.size) }}</td>
<td class="mono muted">{{ formatDateTime(row.modifiedAt) }}</td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!isAdmin || busy"
@click="askDelete([row.name], false)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ConfirmDialog
:open="pending !== null"
tone="danger"
:title="pending?.batch === true ? '删除选中的孤儿图片?' : '删除这张图片?'"
:message="
pending?.batch === true
? `将从服务端的 uploads 目录删除 ${pending?.names.length ?? 0} 个文件,删除后无法恢复。`
: '将从服务端的 uploads 目录删除该文件,删除后无法恢复。若它已被配置引用,服务端会拒绝并说明原因。'
"
:details="confirmDetails"
confirm-text="删除"
:pending="busy"
@confirm="confirmDelete"
@cancel="pending = null"
/>
</section>
</template>
<style scoped>
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.summary {
font-size: 12px;
color: var(--mx-t3);
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.check-col {
width: 34px;
text-align: center;
}
.right {
text-align: right;
}
.name {
font-size: 12px;
word-break: break-all;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.thumb {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: var(--mx-r-xs);
border: 1px solid var(--mx-border);
background: var(--mx-s3);
}
.failure-list {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.state-block {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
padding: 18px 0;
}
</style>
+117 -3
View File
@@ -451,12 +451,29 @@ export interface BackupItem {
size: number
reason: string
createdAt: string
/**
* 备份的性质,由服务端给出(不再让前端从文件名前缀猜):
* system / event / safety(恢复前安全副本);老版本认不出的名字是空串。
*/
kind: BackupKind
/**
* 这份备份对应的库:system / event。安全副本的 kind 是 safety,
* 但恢复请求要传的是 dbKind——它决定覆盖哪一份库。
*/
dbKind: RestoreTargetKind
}
/** 备份文件的性质。 */
export type BackupKind = 'system' | 'event' | 'safety' | ''
/** 恢复请求要覆盖的那份库。 */
export type RestoreTargetKind = 'system' | 'event' | ''
/**
* 两种来源要兼容:
* - GET /api/export/backups 返回 export.BackupInfo(name / size / reason 齐全);
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补。
* 三种来源要兼容:
* - GET /api/export/backups 返回 export.BackupInfo(name / kind / dbKind 齐全);
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补;
* - 示例数据自带 kind / dbKind。
*/
function toBackupItem(value: unknown): BackupItem | null {
if (!isRecord(value)) return null
@@ -470,9 +487,17 @@ function toBackupItem(value: unknown): BackupItem | null {
size: readNumber(value, 'size') ?? 0,
reason: readString(value, 'reason') ?? '',
createdAt,
kind: readBackupKind(value, 'kind'),
dbKind: readBackupKind(value, 'dbKind') as RestoreTargetKind,
}
}
function readBackupKind(source: Record<string, unknown>, key: string): BackupKind {
const value = readString(source, key)
if (value === 'system' || value === 'event' || value === 'safety') return value
return ''
}
function mockBackupList(): BackupItem[] {
const now = Date.now()
return [0, 1, 2].map((offset) => ({
@@ -481,6 +506,8 @@ function mockBackupList(): BackupItem[] {
size: 1_482_752 - offset * 2048,
reason: offset === 0 ? 'manual' : 'auto',
createdAt: new Date(now - offset * 5 * 60_000).toISOString(),
kind: 'event',
dbKind: 'event',
}))
}
@@ -1050,3 +1077,90 @@ export const fontsApi = {
await requestJson<unknown>(`/fonts/${encodeURIComponent(name)}`, { method: 'DELETE' })
},
}
// ---------------------------------------------------------------------------
// 上传图片的删除与孤儿清理
// ---------------------------------------------------------------------------
/** uploads/ 下没有被任何配置引用的图片(GET /api/uploads/orphans)。 */
export interface OrphanImage {
name: string
url: string
size: number
/** RFC3339(服务端按 UTC 下发)。 */
modifiedAt: string
}
export interface OrphanImageList {
items: OrphanImage[]
total: number
totalBytes: number
}
export interface OrphanDeleteFailure {
name: string
reason: string
}
export interface OrphanDeleteResult {
deleted: string[]
failed: OrphanDeleteFailure[]
}
function toOrphanImage(value: unknown): OrphanImage | null {
if (!isRecord(value)) return null
const name = readString(value, 'name')
if (name === null) return null
return {
name,
url: readString(value, 'url') ?? `/uploads/${name}`,
size: readNumber(value, 'size') ?? 0,
modifiedAt: readString(value, 'modifiedAt') ?? '',
}
}
function toOrphanDeleteResult(value: unknown): OrphanDeleteResult {
if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '删除结果响应格式不符', 0, value)
const failed = Array.isArray(value.failed) ? value.failed : []
return {
deleted: readStringArray(value.deleted),
failed: failed.filter(isRecord).map((item) => ({
name: readString(item, 'name') ?? '(未知文件)',
reason: readString(item, 'reason') ?? '删除失败',
})),
}
}
/**
* 上传图片的删除与孤儿清理(admin)。
*
* 一律不降级示例数据:假的孤儿清单会让管理员以为没有垃圾文件要清,
* 而示例模式下的「删除成功」更糟——他会以为文件真的没了。
*/
export const imageAssetsApi = {
async listOrphans(): Promise<OrphanImageList> {
const body = await requestJson<unknown>('/uploads/orphans')
if (!isRecord(body)) throw new ApiError('PARSE_FAILED', '孤儿图片清单响应格式不符', 0, body)
const raw = Array.isArray(body.items) ? body.items : []
const items = raw.map(toOrphanImage).filter((item): item is OrphanImage => item !== null)
return {
items,
total: readNumber(body, 'total') ?? items.length,
totalBytes: readNumber(body, 'totalBytes') ?? 0,
}
},
/** 删除单张图片;仍被引用时服务端回 409,错误文案原样抛出。 */
async remove(name: string): Promise<void> {
await requestJson<unknown>(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' })
},
/** 批量删除明确列出的孤儿;服务端逐条回报,被引用的条目不会删。 */
async removeOrphans(names: string[]): Promise<OrphanDeleteResult> {
const body = await requestJson<unknown>('/uploads/orphans/delete', {
method: 'POST',
json: { names },
})
return toOrphanDeleteResult(body)
},
}