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)
},
}
+40 -20
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime, type AuditLog } from '@/shared'
import { formatDateTime, type AuditLog, type AuditLogQuery } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import MockBadge from '../components/system/MockBadge.vue'
@@ -14,8 +14,8 @@ import { useToastStore } from '../stores/toast'
* 审计日志(devdocs/08 第 3 节)。
*
* 审计日志只读、不可删;所有写操作都记录操作人、时间、IP、request_id 与 before/after。
* 服务端的 /api/audit-logs 只支持 entityType / userId 过滤,没有 action 参数,
* 所以这里一次取最近 200 条再在前端按动作与实体筛选——宁可把窗口写清楚,也不假装能全库筛。
* 服务端的 /api/audit-logs 支持 action(子串模糊)/ entityType / userId 过滤,三者可组合;
* 动作过滤走服务端(全库范围),实体过滤与分页作用于返回的最近 200 条窗口。
*/
const PAGE_SIZE = 20
@@ -36,23 +36,21 @@ const actionFilter = ref('')
const entityFilter = ref('')
const expandedId = ref<number | null>(null)
const actionOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.action))
return [...set].sort((a, b) => a.localeCompare(b))
})
const entityOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.entityType))
return [...set].sort((a, b) => a.localeCompare(b))
})
const filtered = computed(() =>
rows.value.filter(
// 动作过滤在服务端已按子串模糊执行;这里再按同一口径过滤一遍,
// 一是 mock 模式下服务端不存在,二是避免窗口内出现与关键字不符的记录。
const filtered = computed(() => {
const keyword = actionFilter.value.trim().toLowerCase()
return rows.value.filter(
(row) =>
(actionFilter.value === '' || row.action === actionFilter.value) &&
(keyword === '' || row.action.toLowerCase().includes(keyword)) &&
(entityFilter.value === '' || row.entityType === entityFilter.value),
),
)
)
})
const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)))
@@ -74,6 +72,8 @@ function resetFilters(): void {
actionFilter.value = ''
entityFilter.value = ''
page.value = 1
// 动作过滤在服务端执行,清空后要重新取窗口才能恢复全量数据。
void load()
}
function onFilterChange(): void {
@@ -81,6 +81,12 @@ function onFilterChange(): void {
page.value = 1
}
function applyActionFilter(): void {
// 动作过滤要等服务端返回结果,先回第一页再重新拉窗口。
page.value = 1
void load()
}
const isAdmin = computed(() => auth.user?.role === 'admin')
async function load(): Promise<void> {
@@ -90,7 +96,13 @@ async function load(): Promise<void> {
loadError.value = null
try {
// 用局部 mock 标记判断本批数据是不是示例,不读会被并发请求清掉的全局状态。
const result = await adminApi.auditLogsWithMock({ page: 1, size: WINDOW_SIZE })
// 动作过滤交给服务端(全库范围),窗口只决定实体筛选与分页的基数。
const query: AuditLogQuery = {
page: 1,
size: WINDOW_SIZE,
action: actionFilter.value.trim() || undefined,
}
const result = await adminApi.auditLogsWithMock(query)
rows.value = result.data.items
usingMock.value = result.mock
if (page.value > pageCount.value) page.value = 1
@@ -129,10 +141,14 @@ onMounted(() => {
<div class="filter">
<label class="mx-label" for="log-action">动作</label>
<div class="mx-input-wrap">
<select id="log-action" v-model="actionFilter" class="mx-input" @change="onFilterChange">
<option value="">全部动作</option>
<option v-for="action in actionOptions" :key="action" :value="action">{{ action }}</option>
</select>
<input
id="log-action"
v-model="actionFilter"
class="mx-input"
type="search"
placeholder="如 team. / result.publish"
@keyup.enter="applyActionFilter"
/>
</div>
</div>
<div class="filter">
@@ -144,12 +160,16 @@ onMounted(() => {
</select>
</div>
</div>
<button class="mx-btn mx-btn-secondary clear" type="button" :disabled="loading" @click="applyActionFilter">
查询
</button>
<button class="mx-btn mx-btn-ghost clear" type="button" @click="resetFilters">清除筛选</button>
</div>
<p class="hint">
服务端 <code class="mono">/api/audit-logs</code> 只支持按实体与操作人过滤,暂不支持 action 参数,
因此筛选与分页作用于最近 {{ WINDOW_SIZE }} 条记录。
动作由服务端 <code class="mono">/api/audit-logs?action=</code> 按子串模糊过滤(全库范围,
如 <code class="mono">team.</code> 能命中 team.create / team.remove);
实体筛选与分页作用于返回的最近 {{ WINDOW_SIZE }} 条记录。
</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
+5 -1
View File
@@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader.vue'
import UsersPanel from '../components/system/UsersPanel.vue'
import BackupPanel from '../components/system/BackupPanel.vue'
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
import ImageAssetsPanel from '../components/system/ImageAssetsPanel.vue'
import SessionPanel from '../components/system/SessionPanel.vue'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -15,13 +16,15 @@ import { useEventStore } from '../stores/event'
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
*/
type TabKey = 'users' | 'session' | 'backup' | 'event'
type TabKey = 'users' | 'session' | 'backup' | 'images' | 'event'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'users', label: '用户管理' },
// 登录有效期是机器级配置(system.db),与赛事信息那种随赛事包走的配置分开摆。
{ key: 'session', label: '登录有效期' },
{ key: 'backup', label: '备份' },
// 上传图片的孤儿清理:删文件不可撤销,只有管理员能看,所以挂在系统页而不是赛事信息页。
{ key: 'images', label: '图片资源' },
{ key: 'event', label: '赛事信息' },
]
@@ -75,6 +78,7 @@ watch(
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
<SessionPanel v-else-if="isAdmin && activeTab === 'session'" />
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
<ImageAssetsPanel v-else-if="isAdmin && activeTab === 'images'" />
<EventInfoPanel v-else />
</div>
</template>
@@ -0,0 +1,116 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { imageAssetsApi } from '../components/system/systemApi'
/**
* 图片删除 / 孤儿清理接口的前端契约:路径、方法、请求体与响应形状。
*
* 这一组端点的红线是「只删点名的文件」,路径或字段名写错时现场看到的是
* 「点了删除但列表没变」这类静默故障,所以这里用 stub fetch 钉住真正发出去的请求。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function stubFetch(reply: (captured: Captured) => Response): Captured {
const captured: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
captured.url = String(input)
captured.method = init?.method ?? 'GET'
captured.body = init?.body
return reply(captured)
}) as typeof fetch
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
return captured
}
const ORPHAN = {
name: '3f6a1c.png',
url: '/uploads/3f6a1c.png',
size: 2048,
modifiedAt: '2026-09-11T03:04:05Z',
}
test('imageAssetsApi.listOrphans 读 GET /api/uploads/orphans 并带出总数与占用', async () => {
const captured = stubFetch(() =>
jsonResponse({ ok: true, data: { items: [ORPHAN], total: 1, totalBytes: 2048 } }),
)
const list = await imageAssetsApi.listOrphans()
assert.equal(captured.url, '/api/uploads/orphans')
assert.equal(captured.method, 'GET')
assert.deepEqual(list, { items: [ORPHAN], total: 1, totalBytes: 2048 })
})
// 形状不对时必须报解析失败,而不是把 undefined 当成「没有孤儿」骗管理员说目录干净。
test('imageAssetsApi.listOrphans 对非法响应报解析失败', async () => {
stubFetch(() => jsonResponse({ ok: true, data: [ORPHAN] }))
await assert.rejects(() => imageAssetsApi.listOrphans(), (error: Error) => error.message.includes('孤儿图片'))
})
test('imageAssetsApi.remove 以 DELETE 删单张并把文件名转义进路径', async () => {
const captured = stubFetch(() => jsonResponse({ ok: true, data: null }))
await imageAssetsApi.remove('3f6a 1c.png')
assert.equal(captured.url, `/api/uploads/${encodeURIComponent('3f6a 1c.png')}`)
assert.equal(captured.method, 'DELETE')
})
// 被引用的图片服务端回 409:原因必须原样透出,否则管理员不知道该去哪改配置。
test('imageAssetsApi.remove 原样抛出「正在被使用」的 409 文案', async () => {
stubFetch(() =>
jsonResponse(
{
ok: false,
error: {
code: 'CONFLICT',
message: '图片 3f6a1c.png 正在被使用(赛事信息·大屏背景图),请先在对应位置改掉或清除后再删除',
},
},
409,
),
)
await assert.rejects(
() => imageAssetsApi.remove('3f6a1c.png'),
(error: Error) => error.message.includes('正在被使用'),
)
})
test('imageAssetsApi.removeOrphans POST 明确列出的文件名并解析逐条回报', async () => {
const captured = stubFetch(() =>
jsonResponse({
ok: true,
data: { deleted: ['3f6a1c.png'], failed: [{ name: 'aabb.png', reason: '仍被引用(单位「第一中学」的单位图标),已跳过' }] },
}),
)
const result = await imageAssetsApi.removeOrphans(['3f6a1c.png', 'aabb.png'])
assert.equal(captured.url, '/api/uploads/orphans/delete')
assert.equal(captured.method, 'POST')
// 只断言 names:send() 会按幂等约定往请求体里补一个 requestId,整包深比较会被它带偏。
assert.deepEqual((JSON.parse(String(captured.body)) as { names: string[] }).names, [
'3f6a1c.png',
'aabb.png',
])
assert.deepEqual(result.deleted, ['3f6a1c.png'])
assert.equal(result.failed.length, 1)
assert.match(result.failed[0]?.reason ?? '', /仍被引用/)
})
@@ -0,0 +1,152 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 参赛单位「恢复」的前端契约:方法 / 路径、已删除单位的列表开关,以及失败时服务端那句话的传递。
*
* 恢复是「彻底删除」(假删除)的反操作:报名、成绩与积分一行没动,所以服务端只回报
* restored 与恢复后的单位,不重算、不推榜单。这里把契约钉住,免得有人按别的写法改坏。
*/
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例,
// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
test('恢复走 POST /api/teams/{id}/restore 并回报 restored', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
team: { id: 3, code: 'A', name: '第一分公司', shortName: '一司', logoPath: null, color: null, sortOrder: 0, active: true, deletedAt: null },
restored: true,
},
}),
)
const result = await adminApi.client.teams.restore(3)
assert.equal(lastRequest.url, '/api/teams/3/restore')
assert.equal(lastRequest.method, 'POST')
assert.equal(result.restored, true)
assert.equal(result.team.deletedAt, null)
assert.equal(result.team.active, true)
})
// 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,点「恢复」恢复不了还看不出原因。
test('恢复带上 CSRF 双提交 Cookie 的请求头', async () => {
respondWith(() => jsonResponse({ ok: true, data: { team: {}, restored: false } }))
const globalWithDocument = globalThis as { document?: unknown }
globalWithDocument.document = { cookie: 'csrf_token=token-123' }
try {
await adminApi.client.teams.restore(3)
} finally {
delete globalWithDocument.document
}
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123')
})
// 幂等命中:服务端回 restored=false,前端不该把它当成失败。
test('单位本就未删除时 restored=false 也算成功', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
team: { id: 4, code: 'B', name: '第二分公司', shortName: null, logoPath: null, color: null, sortOrder: 1, active: false, deletedAt: null },
restored: false,
},
}),
)
const result = await adminApi.client.teams.restore(4)
assert.equal(result.restored, false)
// 停用的单位不能被「恢复」顺手改成启用。
assert.equal(result.team.active, false)
})
// 列已彻底删除的单位要显式带开关,否则服务端不会把隐藏的行放进来。
test('includeDeleted 拼成 ?includeDeleted=1', async () => {
respondWith(() => jsonResponse({ ok: true, data: [] }))
await adminApi.client.teams.list({ includeDeleted: true })
assert.equal(lastRequest.url, '/api/teams?includeDeleted=1')
assert.equal(lastRequest.method, 'GET')
})
// 默认列表不能带上这个开关:非 admin 带它会被服务端 403,整个单位列表就空了。
test('默认列表不带 includeDeleted 查询串', async () => {
respondWith(() => jsonResponse({ ok: true, data: [] }))
await adminApi.client.teams.list()
assert.equal(lastRequest.url, '/api/teams')
})
// 失败时不吞错误:服务端那句原文(单位不存在 / 编码被占用)必须原样带出。
test('恢复失败时服务端那句原文原样带出', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '单位不存在' } }, 404),
)
await assert.rejects(
() => adminApi.client.teams.restore(999999),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '单位不存在')
return true
},
)
})
test('恢复撞上编码冲突时把 409 的原文带出', async () => {
respondWith(() =>
jsonResponse(
{ ok: false, error: { code: 'CONFLICT', message: '单位编码 A 已被另一个可见单位占用,请先处理那个单位再恢复' } },
409,
),
)
await assert.rejects(
() => adminApi.client.teams.restore(3),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 409)
assert.equal(error.code, 'CONFLICT')
assert.match(error.message, /已被另一个可见单位占用/)
return true
},
)
})