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:
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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user