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
},
)
})
+562
View File
@@ -0,0 +1,562 @@
/**
* 自定义大屏模板的插值引擎(设计稿 devdocs/16 §3):解析 → 渲染 → 收集警告。
*
* 纯函数:不碰 DOM、不执行模板里的任何字节(模板是数据不是代码,这里没有 eval / new Function),
* 所以 `node --test` 能直接跑,M4 的桥只负责把 html 结果写进 iframe。
*
* 失败口径按设计稿 §3.1 / §9:取不到值 → 空串 + 一条警告,不打断渲染;
* 块结构错误 → 返回结构化 error(带行列号),**不抛异常**,由调用方决定要不要降级。
*/
/** 一条警告的成因。渲染期只产这些码,解析期不产警告。 */
export type TemplateWarningCode =
/** 路径取不到值(含中间段缺失、对数字取属性) */
| 'missing'
/** 路径末段取到 null */
| 'null'
/** 不是设计稿支持的路径写法(运算符、过滤器、多余的 /、@ 关键字拼错…) */
| 'invalid-path'
/** ../ 超过一层,或已到最外层再往上 */
| 'parent-depth'
/** {{#each}} 的数据源不是数组 */
| 'not-array'
/** 插值目标是对象/数组/函数,渲染不出文本 */
| 'not-scalar'
export interface TemplateWarning {
code: TemplateWarningCode
/** 触发警告的原始路径文本(不含花括号) */
path: string
/** 中文可读描述,供预览直接显示 */
message: string
/** 1 起 */
line: number
/** 1 起 */
column: number
/** 同一 (code, path) 在本次渲染里出现的次数,最小 1:循环里同一处笔误只报一条 */
count: number
}
/** 解析期的结构错误(设计稿 §3.1 的「硬失败」,但不抛异常,见文件头)。 */
export type TemplateErrorCode =
| 'unclosed-tag'
| 'unclosed-block'
| 'unexpected-close'
| 'block-mismatch'
| 'unexpected-else'
| 'duplicate-else'
| 'unknown-block'
| 'empty-block-path'
export interface TemplateError {
code: TemplateErrorCode
message: string
/** 1 起 */
line: number
/** 1 起,字符(UTF-16 码元)计 */
column: number
/** 0 起的源码偏移,便于预览精确跳转 */
offset: number
}
export interface TextNode {
type: 'text'
value: string
}
export interface ValueNode {
type: 'value'
path: PathRef
/** true = {{{ }}} / {{& }} 原样输出 */
raw: boolean
line: number
column: number
}
export interface SectionNode {
type: 'each' | 'if'
path: PathRef
body: TemplateNode[]
/** {{else}} 分支;没有 else 时为 null */
alternative: TemplateNode[] | null
line: number
column: number
offset: number
}
export type TemplateNode = TextNode | ValueNode | SectionNode
/** 解析后的路径引用:parents 是 ../ 层数,segments 是点号分段。 */
export interface PathRef {
raw: string
parents: number
segments: string[]
/** false = 不是支持的写法,渲染时按空串处理并给 invalid-path 警告 */
valid: boolean
}
export interface ParseResult {
/** error 非 null 时恒为空数组:半截语法树没有可用信息,不如只给错误位置 */
ast: TemplateNode[]
error: TemplateError | null
}
export interface RenderResult {
html: string
warnings: TemplateWarning[]
/** 结构错误;非 null 时 html 恒为空串 */
error: TemplateError | null
}
/** 只有数组下标与 length 能在数组上取,其它键一律算取不到(设计稿 §3.5 的空串口径)。 */
const ARRAY_INDEX_RE = /^[0-9]+$/
/** 路径分段允许的字符:数据键都是 ASCII,表达式(空格、+、|)会在这里被判为非法。 */
const SEGMENT_RE = /^[A-Za-z0-9_$@]+$/
const LOOP_KEYS = new Set(['@index', '@first', '@last'])
const BLOCK_KEYWORDS = new Set(['each', 'if'])
const ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
function escapeHtml(input: string): string {
return input.replace(/[&<>"']/g, (ch) => ESCAPES[ch] ?? ch)
}
/**
* 设计稿 §3.3 写死的真值表,不要在这里加 JS 的隐式转换:
* ''、0、NaN、[]、{} 一律为假;' '、'0' 这类非空串为真。
*/
function isTruthy(value: unknown): boolean {
if (value === undefined || value === null || value === false) return false
if (typeof value === 'number') return value !== 0 && !Number.isNaN(value)
if (typeof value === 'string') return value !== ''
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value as object).length > 0
return true
}
/**
* 解析点号路径。非法路径不抛错(设计稿 §3.1:未知 token → 空串 + 警告),
* 但要在这里先判死,渲染时才不用为每个分段重复兜底。
*/
function parsePath(raw: string): PathRef {
let rest = raw
let parents = 0
while (rest.startsWith('../')) {
parents += 1
rest = rest.slice(3)
}
// 设计稿 §3.1:../ 最多一层,多层按空串处理;这里把带 / 的剩余写法一并判非法
if (rest.includes('/')) return { raw, parents, segments: [], valid: false }
const segments = rest === '' ? [] : rest.split('.')
if (segments.length === 0) return { raw, parents, segments, valid: false }
for (const segment of segments) {
if (!SEGMENT_RE.test(segment)) return { raw, parents, segments, valid: false }
if (segment.startsWith('@') && !LOOP_KEYS.has(segment)) {
return { raw, parents, segments, valid: false }
}
}
return { raw, parents, segments, valid: true }
}
interface TextToken {
kind: 'text'
value: string
start: number
end: number
}
interface TagToken {
kind: 'value' | 'open' | 'close' | 'else'
/** open/close 上的块名 */
block: 'each' | 'if' | null
rawPath: string
raw: boolean
start: number
end: number
line: number
column: number
}
type Token = TextToken | TagToken
interface Frame {
value: unknown
/** 非循环帧(根)为 -1 */
index: number
total: number
}
const WARNING_TEXT: Record<TemplateWarningCode, (path: string) => string> = {
missing: (p) => `路径「${p}」取不到值,已按空串渲染`,
null: (p) => `路径「${p}」的值为 null,已按空串渲染`,
'invalid-path': (p) => `「${p}」不是支持的写法(只支持点号路径、this、@index/@first/@last 与一层 ../),已按空串处理`,
'parent-depth': (p) => `「${p}」的 ../ 超过一层或已到最外层,已按空串处理`,
'not-array': (p) => `「${p}」不是数组,{{#each}} 未输出任何内容`,
'not-scalar': (p) => `「${p}」是对象/数组,插值只能渲染文本,已按空串处理`,
}
const ERROR_TEXT: Record<TemplateErrorCode, string> = {
'unclosed-tag': '标签没有闭合的 }}',
'unclosed-block': '块没有闭合的结束标记',
'unexpected-close': '多出来的结束标记',
'block-mismatch': '结束标记与最近的块不配对',
'unexpected-else': '{{else}} 只能出现在 {{#if}} 块内',
'duplicate-else': '同一个 {{#if}} 块里出现了多个 {{else}}',
'unknown-block': '不支持的块标记',
'empty-block-path': '块标记缺少路径',
}
/** 同一 (code, path) 只留第一条,重复出现只累加 count:循环里的笔误不该刷满警告清单。 */
class WarningCollector {
private readonly seen = new Map<string, TemplateWarning>()
private readonly list: TemplateWarning[] = []
warn(code: TemplateWarningCode, path: string, line: number, column: number): void {
const key = `${code}|${path}`
const hit = this.seen.get(key)
if (hit) {
hit.count += 1
return
}
const warning: TemplateWarning = {
code,
path,
message: WARNING_TEXT[code](path),
line,
column,
count: 1,
}
this.seen.set(key, warning)
this.list.push(warning)
}
get warnings(): TemplateWarning[] {
return this.list
}
}
function buildLineStarts(source: string): number[] {
const starts = [0]
for (let i = 0; i < source.length; i += 1) {
if (source.charCodeAt(i) === 10) starts.push(i + 1)
}
return starts
}
function positionAt(starts: number[], offset: number): { line: number; column: number } {
let lo = 0
let hi = starts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (starts[mid] <= offset) lo = mid
else hi = mid - 1
}
return { line: lo + 1, column: offset - starts[lo] + 1 }
}
function makeError(
code: TemplateErrorCode,
starts: number[],
offset: number,
detail = '',
): TemplateError {
const { line, column } = positionAt(starts, offset)
return {
code,
message: detail === '' ? ERROR_TEXT[code] : `${ERROR_TEXT[code]}:${detail}`,
line,
column,
offset,
}
}
function tokenize(
source: string,
starts: number[],
): { tokens: Token[]; error: TemplateError | null } {
const tokens: Token[] = []
let cursor = 0
let textStart = 0
while (cursor < source.length) {
const open = source.indexOf('{{', cursor)
if (open < 0) break
if (open > textStart) {
tokens.push({ kind: 'text', value: source.slice(textStart, open), start: textStart, end: open })
}
const triple = source.startsWith('{{{', open)
const contentStart = open + (triple ? 3 : 2)
const close = source.indexOf(triple ? '}}}' : '}}', contentStart)
if (close < 0) {
return { tokens, error: makeError('unclosed-tag', starts, open) }
}
const end = close + (triple ? 3 : 2)
const inner = source.slice(contentStart, close).trim()
const { line, column } = positionAt(starts, open)
if (inner.startsWith('#')) {
const rest = inner.slice(1).trim()
const keyword = rest.split(/\s/)[0] ?? ''
if (!BLOCK_KEYWORDS.has(keyword)) {
return { tokens, error: makeError('unknown-block', starts, open, `{{#${rest}}} 不是 {{#each}} 或 {{#if}}`) }
}
// 操作数取「关键字之后的整段」:{{#if rank > 3}} 要整段判为非法路径 → 假(设计稿 §3.3),
// 不能只取第一个词,否则会解析成一个能取到值的 rank 而变成真。
const rawPath = rest.slice(keyword.length).trim()
if (rawPath === '') {
return { tokens, error: makeError('empty-block-path', starts, open, `{{#${keyword}}}`) }
}
tokens.push({ kind: 'open', block: keyword as 'each' | 'if', rawPath, raw: false, start: open, end, line, column })
} else if (inner.startsWith('/')) {
const keyword = inner.slice(1).trim()
if (!BLOCK_KEYWORDS.has(keyword)) {
return { tokens, error: makeError('unknown-block', starts, open, `{{/${keyword}}} 不是 {{/each}} 或 {{/if}}`) }
}
tokens.push({ kind: 'close', block: keyword as 'each' | 'if', rawPath: '', raw: false, start: open, end, line, column })
} else if (inner === 'else') {
tokens.push({ kind: 'else', block: null, rawPath: '', raw: false, start: open, end, line, column })
} else if (!triple && inner.startsWith('&')) {
// {{& x}} 与 {{{ x }}} 同义(设计稿 §3.4)
tokens.push({ kind: 'value', block: null, rawPath: inner.slice(1).trim(), raw: true, start: open, end, line, column })
} else {
tokens.push({ kind: 'value', block: null, rawPath: inner, raw: triple, start: open, end, line, column })
}
cursor = end
textStart = end
}
if (textStart < source.length) {
tokens.push({ kind: 'text', value: source.slice(textStart), start: textStart, end: source.length })
}
return { tokens, error: null }
}
/**
* Mustache 的 standalone line 规则(设计稿 §3.2):一行里只有空白 + 一个块标记时,
* 整行(含缩进与换行)吞掉。只对块标记生效,插值行照旧保留——缩进写模板不该在输出里留空行。
*/
function stripStandaloneLines(source: string, tokens: Token[]): void {
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i]
if (token.kind === 'text' || token.kind === 'value') continue
const lineStart = source.lastIndexOf('\n', token.start - 1) + 1
if (/[^\t ]/.test(source.slice(lineStart, token.start))) continue
const newline = source.indexOf('\n', token.end)
const lineEnd = newline < 0 ? source.length : newline
// \r 归前一行(CRLF 模板),所以行尾只允许空白与 \r
if (/[^\t\r ]/.test(source.slice(token.end, lineEnd))) continue
const prev = tokens[i - 1]
if (prev && prev.kind === 'text') prev.value = prev.value.replace(/[\t ]*$/, '')
const next = tokens[i + 1]
// 只吃掉「标记尾随空白 + 换行」,下一行自己的缩进要留着
if (next && next.kind === 'text') next.value = next.value.replace(/^[\t ]*\r?\n/, '')
}
}
function buildAst(
tokens: Token[],
starts: number[],
): { ast: TemplateNode[]; error: TemplateError | null } {
const root: TemplateNode[] = []
interface Entry {
node: SectionNode | null
list: TemplateNode[]
part: 'body' | 'alternative'
}
const stack: Entry[] = [{ node: null, list: root, part: 'body' }]
const top = (): Entry => stack[stack.length - 1]
for (const token of tokens) {
if (token.kind === 'text') {
if (token.value !== '') top().list.push({ type: 'text', value: token.value })
continue
}
if (token.kind === 'value') {
top().list.push({
type: 'value',
path: parsePath(token.rawPath),
raw: token.raw,
line: token.line,
column: token.column,
})
continue
}
if (token.kind === 'open') {
const node: SectionNode = {
type: token.block as 'each' | 'if',
path: parsePath(token.rawPath),
body: [],
alternative: null,
line: token.line,
column: token.column,
offset: token.start,
}
top().list.push(node)
stack.push({ node, list: node.body, part: 'body' })
continue
}
if (token.kind === 'else') {
const entry = top()
if (!entry.node || entry.node.type !== 'if') {
return { ast: [], error: makeError('unexpected-else', starts, token.start) }
}
if (entry.part === 'alternative') {
return { ast: [], error: makeError('duplicate-else', starts, token.start) }
}
entry.node.alternative = []
entry.list = entry.node.alternative
entry.part = 'alternative'
continue
}
// close
if (stack.length === 1) {
return { ast: [], error: makeError('unexpected-close', starts, token.start, `{{/${token.block}}}`) }
}
const entry = top()
if (!entry.node || entry.node.type !== token.block) {
return {
ast: [],
error: makeError('block-mismatch', starts, token.start, `{{/${token.block}}} 与最近的 {{#${entry.node?.type ?? '?'}}} 不配对`),
}
}
stack.pop()
}
if (stack.length > 1) {
const unclosed = stack[1].node
return { ast: [], error: makeError('unclosed-block', starts, unclosed?.offset ?? 0, `{{#${unclosed?.type ?? '?'}}}`) }
}
return { ast: root, error: null }
}
function resolvePath(ref: PathRef, stack: Frame[]): { value: unknown; code: TemplateWarningCode | null } {
if (!ref.valid) return { value: undefined, code: 'invalid-path' }
if (ref.parents > 1) return { value: undefined, code: 'parent-depth' }
const baseIndex = stack.length - 1 - ref.parents
if (baseIndex < 0) return { value: undefined, code: 'parent-depth' }
const frame = stack[baseIndex]
let segments = ref.segments
if (segments[0]?.startsWith('@')) {
if (segments.length > 1) return { value: undefined, code: 'invalid-path' }
// 循环外没有 @index:按取不到处理(设计稿 §3.1 未定义 → 空串)
if (baseIndex === 0) return { value: undefined, code: 'missing' }
if (segments[0] === '@index') return { value: frame.index, code: null }
if (segments[0] === '@first') return { value: frame.index === 0, code: null }
return { value: frame.index === frame.total - 1, code: null }
}
// 设计稿 §3.2:循环体内 this.xxx 与直接 xxx 等价
if (segments[0] === 'this') segments = segments.slice(1)
let current = frame.value
for (const segment of segments) {
if (current === undefined) return { value: undefined, code: 'missing' }
if (current === null) return { value: undefined, code: 'null' }
if (Array.isArray(current)) {
if (segment === 'length') {
current = current.length
continue
}
if (!ARRAY_INDEX_RE.test(segment)) return { value: undefined, code: 'missing' }
const index = Number(segment)
if (index >= current.length) return { value: undefined, code: 'missing' }
current = current[index]
continue
}
if (typeof current === 'object') {
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
return { value: undefined, code: 'missing' }
}
current = (current as Record<string, unknown>)[segment]
continue
}
// 对数字/布尔/字符串取属性 → 空串(设计稿 §3.5)
return { value: undefined, code: 'missing' }
}
if (current === undefined) return { value: undefined, code: 'missing' }
if (current === null) return { value: undefined, code: 'null' }
return { value: current, code: null }
}
function renderNodes(nodes: TemplateNode[], stack: Frame[], out: string[], sink: WarningCollector): void {
for (const node of nodes) {
if (node.type === 'text') {
out.push(node.value)
continue
}
if (node.type === 'value') {
const resolved = resolvePath(node.path, stack)
if (resolved.code) sink.warn(resolved.code, node.path.raw, node.line, node.column)
const { text, code } = valueToText(resolved.value)
if (code) sink.warn(code, node.path.raw, node.line, node.column)
out.push(node.raw ? text : escapeHtml(text))
continue
}
const resolved = resolvePath(node.path, stack)
if (resolved.code) sink.warn(resolved.code, node.path.raw, node.line, node.column)
if (node.type === 'if') {
if (isTruthy(resolved.value)) renderNodes(node.body, stack, out, sink)
else if (node.alternative) renderNodes(node.alternative, stack, out, sink)
continue
}
// 设计稿 §3.2:只支持数组;null/undefined 前面已按空值告警,非数组另告一条
if (Array.isArray(resolved.value)) {
const total = resolved.value.length
for (let i = 0; i < total; i += 1) {
renderNodes(node.body, stack.concat({ value: resolved.value[i], index: i, total }), out, sink)
}
} else if (resolved.value !== undefined && resolved.value !== null) {
sink.warn('not-array', node.path.raw, node.line, node.column)
}
}
}
/**
* 对象/数组不落成 [object Object]:屏上要的是文本,渲染不出来就给空串 + 警告。
* undefined/null 不在这里告警——取值阶段已经报过 missing/null,重复报会刷屏。
*/
function valueToText(value: unknown): { text: string; code: TemplateWarningCode | null } {
if (value === undefined || value === null) return { text: '', code: null }
if (typeof value === 'string') return { text: value, code: null }
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return { text: String(value), code: null }
}
return { text: '', code: 'not-scalar' }
}
export function parseTemplate(source: string): ParseResult {
const starts = buildLineStarts(source)
const { tokens, error } = tokenize(source, starts)
if (error) return { ast: [], error }
stripStandaloneLines(source, tokens)
return buildAst(tokens, starts)
}
/** 渲染。任何情况下都不抛异常:结构错误走返回值里的 error,取不到值走 html 空串 + warnings。 */
export function renderTemplate(source: string, data: unknown = {}): RenderResult {
const { ast, error } = parseTemplate(source)
if (error) return { html: '', warnings: [], error }
const sink = new WarningCollector()
const out: string[] = []
renderNodes(ast, [{ value: data, index: -1, total: -1 }], out, sink)
return { html: out.join(''), warnings: sink.warnings, error: null }
}
@@ -0,0 +1,141 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 块语法口径:设计稿 §3.2(#each)、§3.3(#if/else 真值表)、§3.2 的 standalone line 吞行规则。
test('#each:空数组不输出循环体,也不产生警告', () => {
const result = renderTemplate('<ul>{{#each rows}}<li>{{ this }}</li>{{/each}}</ul>', { rows: [] })
assert.equal(result.html, '<ul></ul>')
assert.deepEqual(result.warnings, [])
})
test('#each:null/undefined 当空数组,给一条警告', () => {
const missing = renderTemplate('[{{#each rows}}x{{/each}}]', {})
assert.equal(missing.html, '[]')
assert.equal(missing.warnings.length, 1)
assert.equal(missing.warnings[0].code, 'missing')
const nullCase = renderTemplate('[{{#each rows}}x{{/each}}]', { rows: null })
assert.equal(nullCase.html, '[]')
assert.equal(nullCase.warnings[0].code, 'null')
})
test('#each:非数组只告警不渲染(设计稿 §3.2 只支持数组)', () => {
const result = renderTemplate('[{{#each rows}}x{{/each}}]', { rows: 'abc' })
assert.equal(result.html, '[]')
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].code, 'not-array')
})
test('#each:多条 + @index/@first/@last', () => {
const source = '{{#each rows}}{{ @index }}:{{ this }}:{{ @first }}:{{ @last }};{{/each}}'
const result = renderTemplate(source, { rows: ['a', 'b'] })
assert.equal(result.html, '0:a:true:false;1:b:false:true;')
assert.deepEqual(result.warnings, [])
})
test('#each:this.xxx 与直接 xxx 等价(设计稿 §3.2)', () => {
const data = { rows: [{ n: 1 }, { n: 2 }] }
assert.equal(renderTemplate('{{#each rows}}{{ this.n }}/{{ n }},{{/each}}', data).html, '1/1,2/2,')
})
test('#each:嵌套循环里 this 逐层生效、../ 弹一层', () => {
const data = { groups: [{ name: 'A', items: ['x', 'y'] }, { name: 'B', items: ['z'] }] }
const source = '{{#each groups}}{{#each this.items}}{{ ../this.name }}:{{ this }};{{/each}}{{/each}}'
const result = renderTemplate(source, data)
assert.equal(result.html, 'A:x;A:y;B:z;')
assert.deepEqual(result.warnings, [])
})
test('未定义路径作 #each 数据源:嵌套时仍按空数组处理', () => {
const result = renderTemplate('{{#each groups}}{{#each this.items}}{{ this }}{{/each}}{{#each this.absent}}x{{/each}}{{/each}}', {
groups: [{ items: ['a'] }],
})
assert.equal(result.html, 'a')
assert.equal(result.warnings[0].code, 'missing')
assert.equal(result.warnings[0].path, 'this.absent')
})
test('../ 超过一层:空串 + parent-depth 警告(设计稿 §3.1)', () => {
const result = renderTemplate('{{#each rows}}{{ ../../a }}{{/each}}', { a: 1, rows: [1] })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'parent-depth')
})
test('../ 在根作用域:空串 + parent-depth 警告', () => {
const result = renderTemplate('{{ ../a }}', { a: 1 })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'parent-depth')
})
test('循环外取 @index:空串 + 警告', () => {
const result = renderTemplate('{{ @index }}', {})
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'missing')
})
test('#if:真值表按设计稿 §3.3 写死', () => {
const cases: Array<[unknown, boolean]> = [
[undefined, false],
[null, false],
[false, false],
['', false],
[0, false],
[Number.NaN, false],
[[], false],
[{}, false],
[' ', true],
['0', true],
[1, true],
[-1, true],
[[0], true],
[{ a: 1 }, true],
['a', true],
[true, true],
]
for (const [value, expected] of cases) {
const result = renderTemplate('{{#if v}}T{{else}}F{{/if}}', { v: value })
assert.equal(result.html, expected ? 'T' : 'F', `v=${String(value)}`)
}
})
test('#if:走不到的分支不求值(分支里的缺失路径不产生警告)', () => {
const result = renderTemplate('{{#if ok}}{{ a.b.c }}{{else}}{{ d.e }}{{/if}}', { ok: false, d: { e: 'hit' } })
assert.equal(result.html, 'hit')
assert.deepEqual(result.warnings, [])
})
test('#if/else:跨行写法的两条分支', () => {
const source = ['{{#if rows.length}}', ' 有 {{ rows.length }} 条', '{{else}}', ' 暂无数据', '{{/if}}'].join('\n')
assert.equal(renderTemplate(source, { rows: [1, 2] }).html, ' 有 2 条\n')
assert.equal(renderTemplate(source, { rows: [] }).html, ' 暂无数据\n')
})
test('#if 里的比较运算按非法路径处理 → 假(设计稿 §3.3)', () => {
const result = renderTemplate('{{#if rank > 3}}T{{else}}F{{/if}}', { rank: 9 })
assert.equal(result.html, 'F')
assert.equal(result.warnings[0].code, 'invalid-path')
})
test('standalone line:只有空白 + 块标记的行整行吞掉,缩进模板不留空行', () => {
const source = '<div>\n {{#each rows}}\n <span>{{ this }}</span>\n {{/each}}\n</div>'
const result = renderTemplate(source, { rows: ['a', 'b'] })
// {{#each}} / {{/each}} 那两行连缩进与换行一起消失;循环体自己那行的缩进保留
assert.equal(result.html, '<div>\n <span>a</span>\n <span>b</span>\n</div>')
})
test('standalone line:空数组时不留下循环那两行的空白', () => {
const source = '<div>\n {{#each rows}}\n <span>{{ this }}</span>\n {{/each}}\n</div>'
assert.equal(renderTemplate(source, { rows: [] }).html, '<div>\n</div>')
})
test('standalone line:行内块与插值行都不吞行', () => {
assert.equal(renderTemplate('x{{#if v}}y{{/if}}z', { v: true }).html, 'xyz')
assert.equal(renderTemplate('<div>\n {{ v }}\n</div>', { v: 'a' }).html, '<div>\n a\n</div>')
})
test('standalone line:块标记周围有其它字符就不吞行', () => {
const result = renderTemplate('<div>\n {{#if v}}x{{/if}}\n</div>', { v: true })
assert.equal(result.html, '<div>\n x\n</div>')
})
@@ -0,0 +1,104 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseTemplate, renderTemplate } from '../template'
// 结构错误口径:设计稿 §3.1 说「硬失败」,§9 说要降级、不能白屏,§10.1 要「结构化 ParseError(含位置)」。
// 这里的落法是:引擎不抛异常,把结构化错误(码 + 行列号 + 偏移)放在返回值里,由调用方决定降级。
test('未闭合的 {{#each}}:返回 unclosed-block,html 为空,不抛异常', () => {
const source = 'line1\n{{#each rows}}\n <span>{{ this }}</span>\n'
const result = renderTemplate(source, { rows: ['a'] })
assert.equal(result.error?.code, 'unclosed-block')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 1)
assert.equal(result.error?.offset, 6)
assert.equal(result.html, '')
assert.deepEqual(result.warnings, [])
})
test('未闭合的 {{#if}}:返回 unclosed-block,指向最外层没闭合的块', () => {
const source = '{{#if a}}\n{{#if b}}\nx\n{{/if}}\n'
const result = renderTemplate(source, { a: true, b: true })
assert.equal(result.error?.code, 'unclosed-block')
assert.equal(result.error?.line, 1)
assert.equal(result.html, '')
})
test('多出来的 {{/each}}:返回 unexpected-close', () => {
const result = renderTemplate('x\n{{/each}}', {})
assert.equal(result.error?.code, 'unexpected-close')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 1)
})
test('错配的 {{/if}}:返回 block-mismatch,消息里点出两个块名', () => {
const result = renderTemplate('{{#each rows}}x{{/if}}', { rows: [] })
assert.equal(result.error?.code, 'block-mismatch')
assert.ok(result.error?.message.includes('each'))
assert.ok(result.error?.message.includes('if'))
})
test('{{else}} 出现在 {{#each}} 内:返回 unexpected-else', () => {
const result = renderTemplate('{{#each rows}}a{{else}}b{{/each}}', { rows: [] })
assert.equal(result.error?.code, 'unexpected-else')
})
test('{{else}} 出现在块外:返回 unexpected-else', () => {
const result = renderTemplate('{{else}}', {})
assert.equal(result.error?.code, 'unexpected-else')
})
test('同一个 {{#if}} 里两个 {{else}}:返回 duplicate-else', () => {
const result = renderTemplate('{{#if a}}1{{else}}2{{else}}3{{/if}}', { a: true })
assert.equal(result.error?.code, 'duplicate-else')
})
test('{{ 不配对:返回 unclosed-tag 并给出精确位置', () => {
const result = renderTemplate('a\n b {{ x', {})
assert.equal(result.error?.code, 'unclosed-tag')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 4)
assert.equal(result.error?.offset, 5)
})
test('{{{ 不配对:返回 unclosed-tag', () => {
const result = renderTemplate('{{{ x }}', { x: 1 })
assert.equal(result.error?.code, 'unclosed-tag')
assert.equal(result.html, '')
})
test('不支持的块标记:返回 unknown-block', () => {
assert.equal(renderTemplate('{{#unless a}}x{{/unless}}', {}).error?.code, 'unknown-block')
assert.equal(renderTemplate('{{#each rows}}x{{/with}}', {}).error?.code, 'unknown-block')
})
test('块标记缺路径:返回 empty-block-path', () => {
assert.equal(renderTemplate('{{#if}}x{{/if}}', {}).error?.code, 'empty-block-path')
assert.equal(renderTemplate('{{#each}}x{{/each}}', {}).error?.code, 'empty-block-path')
})
test('结构错误一律不抛异常(设计稿 §9:模板问题不得打挂大屏)', () => {
const broken = ['{{#each rows}}', '{{/if}}', '{{', '{{{', '{{#unless a}}', '{{else}}', '{{#each rows}}{{/if}}']
for (const source of broken) {
assert.doesNotThrow(() => renderTemplate(source, { rows: [] }), source)
assert.ok(renderTemplate(source, { rows: [] }).error !== null, source)
}
})
test('解析通过的模板 error 为 null,ast 非空', () => {
const parsed = parseTemplate('{{ a }}')
assert.equal(parsed.error, null)
assert.equal(parsed.ast.length, 1)
assert.equal(parsed.ast[0].type, 'value')
const broken = parseTemplate('{{#each rows}}')
assert.equal(broken.error?.code, 'unclosed-block')
assert.deepEqual(broken.ast, [])
})
test('行号按实际换行统计(\\r\\n 模板也不偏)', () => {
const result = renderTemplate('a\r\nb\r\n{{#each rows}}\r\nx\r\n', { rows: [] })
assert.equal(result.error?.line, 3)
})
@@ -0,0 +1,132 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// M1 的验收目标(设计稿 §3.7、§11.1):把「社区积分榜」示例模板用一份构造数据渲染出来。
// 这里保留示例的缩进与块标记位置,顺带验证 standalone line 吞行不会在输出里留下空行。
const SOURCE = `<div class="wrap">
<h1>{{ settings.display.title }}</h1>
<div class="sub">{{ settings.event.name }} · 共 {{ standings.length }} 个单位</div>
{{#if standings.length}}
<div class="row head"><span class="rk">名次</span><span class="nm">单位</span><span></span><span class="pts">总分</span></div>
{{#each standings}}
<div class="row">
<span class="rk top{{ this.rank }}">{{ this.rank }}</span>
<span class="nm">
{{{ this.logoImg }}}
<span>{{ this.teamName }}</span>
</span>
<span>
{{#if this.rankChange}}<b class="{{ this.rankUpDownClass }}">{{ this.rankChangeText }}</b>{{/if}}
</span>
<span class="pts">{{ this.totalPoints }}</span>
</div>
{{/each}}
{{else}}
<div class="empty">暂无积分数据</div>
{{/if}}
</div>`
// logoImg / rankChangeText / rankUpDownClass 是设计稿 §4.3 说的「桥补的便利字段」,
// M1 只消费它们,不由引擎生成。
function createData() {
return {
settings: {
display: { title: '社区积分榜' },
event: { name: '第十二届社区运动会' },
},
standings: [
{
rank: 1,
teamName: '阳光社区',
totalPoints: 128,
rankChange: 2,
rankChangeText: '▲2',
rankUpDownClass: 'up',
logoImg: '<img src="/uploads/sun.png" alt="">',
},
{
rank: 2,
teamName: '晨曦社区',
totalPoints: 96,
rankChange: 0,
rankChangeText: '',
rankUpDownClass: '',
logoImg: '',
},
{
rank: 3,
teamName: 'A&B <社区>',
totalPoints: 88,
rankChange: null,
rankChangeText: '',
rankUpDownClass: '',
logoImg: '',
},
],
}
}
test('示例一:纯插值「社区积分榜」的关键片段', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.error, null)
assert.equal(result.html.includes('<h1>社区积分榜</h1>'), true, '标题插值')
assert.equal(result.html.includes('第十二届社区运动会 · 共 3 个单位'), true, '赛事名与数组长度')
assert.equal(result.html.includes('<span class="rk">名次</span>'), true, '有数据时走 #if 真分支的表头')
assert.equal(result.html.includes('<span class="rk top1">1</span>'), true, '名次参与 class 拼接')
assert.equal(result.html.includes('<b class="up">▲2</b>'), true, '上升箭头')
assert.equal(result.html.includes('<span class="pts">128</span>'), true, '总分')
assert.equal(result.html.includes('暂无积分数据'), false, '有数据时不输出空态')
})
test('示例一:三花括号的 logoImg 原样落成标签,文本字段仍然转义', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.html.includes('<img src="/uploads/sun.png" alt="">'), true, 'logoImg 不该被实体化')
assert.equal(result.html.includes('A&amp;B &lt;社区&gt;'), true, '单位名里的 & 与尖括号被转义')
assert.equal(result.html.includes('A&B <社区>'), false)
})
test('示例一:空值处渲染成空串,输出里不出现 undefined / null / [object Object]', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.html.includes('undefined'), false)
assert.equal(result.html.includes('[object Object]'), false)
assert.equal(result.html.includes('>null<'), false)
// 第三行 rankChange 为 null:不输出箭头 <b>,那一格是空串
const thirdRow = result.html.slice(result.html.indexOf('A&amp;B &lt;社区&gt;'))
assert.equal(thirdRow.includes('<b class'), false)
})
test('示例一:standings 为空时走 {{else}} 空态分支', () => {
const data = { ...createData(), standings: [] }
const result = renderTemplate(SOURCE, data)
assert.equal(result.html.includes('暂无积分数据'), true)
assert.equal(result.html.includes('名次'), false, '空态不该输出表头')
assert.equal(result.html.includes('共 0 个单位'), true)
assert.equal(result.error, null)
})
test('示例一:整体渲染不产生结构错误,警告只来自按设计缺失的字段', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.error, null)
for (const warning of result.warnings) {
assert.equal(warning.code, 'null')
assert.equal(warning.path, 'this.rankChange')
}
assert.equal(result.warnings.length, 1)
})
test('示例一:空结果里循环那两行不留下空白行', () => {
const data = { ...createData(), standings: [] }
const result = renderTemplate(SOURCE, data)
assert.equal(result.html.includes('\n \n'), false, 'standalone 块标记的行应当整行消失')
// 循环体整段没输出,接在空态分支前面的只有模板里那个空行
assert.equal(result.html.includes('\n <div class="empty">暂无积分数据</div>\n</div>'), true)
})
@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 设计稿 §10.1 的「边界/性能」组:单文件上限是 1 MiB(§6.2),引擎不能在这个量级上炸或卡死。
test('1 MiB 量级的模板能正常渲染', () => {
const filler = 'x'.repeat(1024)
const source = `${filler}\n`.repeat(1024) + '<b>{{ title }}</b>\n'
assert.ok(source.length > 1024 * 1024, '构造的模板要超过 1 MiB')
const result = renderTemplate(source, { title: '超长模板' })
assert.equal(result.error, null)
assert.equal(result.html.endsWith('<b>超长模板</b>\n'), true)
assert.deepEqual(result.warnings, [])
})
test('1000 项循环:结果正确,同一处缺失路径只留一条警告', () => {
const rows = Array.from({ length: 1000 }, (_, i) => ({ n: i }))
const result = renderTemplate('{{#each rows}}{{ this.n }};{{/each}}', { rows })
assert.equal(result.error, null)
assert.equal(result.html.startsWith('0;1;2;'), true)
assert.equal(result.html.endsWith('999;'), true)
const missing = renderTemplate('{{#each rows}}{{ this.absent }}{{/each}}', { rows })
assert.equal(missing.html, '')
assert.equal(missing.warnings.length, 1)
assert.equal(missing.warnings[0].count, 1000)
})
test('深嵌套 #each/#if(30 层)不炸,作用域逐层正确', () => {
const depth = 30
let source = ''
const root: Record<string, unknown> = { name: 'root' }
let cursor = root
for (let i = 0; i < depth; i += 1) {
source += `{{#each level${i}}}`
const inner: Record<string, unknown> = { name: `n${i}` }
cursor[`level${i}`] = [inner]
cursor = inner
}
source += '{{ this.name }}'
for (let i = 0; i < depth; i += 1) source += '{{/each}}'
const result = renderTemplate(source, root)
assert.equal(result.error, null)
assert.equal(result.html, `n${depth - 1}`)
})
@@ -0,0 +1,138 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 插值通道的取值与转义口径:设计稿 §3.1(路径)、§3.4(转义)、§3.5(未定义=空串 + 警告)。
// 引擎是纯函数,这里全部直接调 renderTemplate,不依赖 Vue / DOM。
test('插值:多级路径与数组下标', () => {
const data = { settings: { display: { title: '春季运动会' } }, standings: [{ teamName: '甲' }, { teamName: '乙' }] }
const result = renderTemplate('{{ settings.display.title }}|{{ standings.0.teamName }}|{{standings.1.teamName}}', data)
assert.equal(result.html, '春季运动会|甲|乙')
assert.equal(result.error, null)
assert.deepEqual(result.warnings, [])
})
test('插值:路径外的空白不参与取值', () => {
const result = renderTemplate('{{ v }}', { v: 'ok' })
assert.equal(result.html, 'ok')
})
test('未定义变量:渲染成空串,只报一条警告,不抛异常', () => {
const result = renderTemplate('<td>{{ settings.display.missing }}</td>', { settings: { display: {} } })
assert.equal(result.html, '<td></td>')
assert.equal(result.error, null)
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].code, 'missing')
assert.equal(result.warnings[0].path, 'settings.display.missing')
assert.equal(result.warnings[0].count, 1)
assert.equal(result.warnings[0].line, 1)
assert.ok(result.warnings[0].message.includes('settings.display.missing'))
assert.ok(!result.html.includes('undefined'))
})
test('深层缺失:中间节点不存在也是空串', () => {
const result = renderTemplate('{{ a.b.c }}', {})
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'missing')
})
test('null 中间节点:空串 + null 警告', () => {
const result = renderTemplate('{{ a.b.c }}', { a: { b: null } })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'null')
})
test('null 叶子:空串 + null 警告', () => {
const result = renderTemplate('[{{ a }}]', { a: null })
assert.equal(result.html, '[]')
assert.equal(result.warnings[0].code, 'null')
})
test('对数字/字符串取属性:空串 + 警告(设计稿 §3.5 的例子 version.x)', () => {
const numberCase = renderTemplate('{{ version.x }}', { version: 3 })
assert.equal(numberCase.html, '')
assert.equal(numberCase.warnings[0].code, 'missing')
const stringCase = renderTemplate('{{ name.length }}', { name: 'abc' })
assert.equal(stringCase.html, '')
assert.equal(stringCase.warnings[0].code, 'missing')
})
test('数组越界与用非下标取数组:空串 + 警告', () => {
const outOfRange = renderTemplate('{{ rows.9 }}', { rows: ['a'] })
assert.equal(outOfRange.html, '')
assert.equal(outOfRange.warnings[0].code, 'missing')
const nonIndex = renderTemplate('{{ rows.foo }}', { rows: ['a'] })
assert.equal(nonIndex.html, '')
assert.equal(nonIndex.warnings[0].code, 'missing')
})
test('数组的 length 可以取', () => {
const result = renderTemplate('共 {{ rows.length }} 条', { rows: ['a', 'b'] })
assert.equal(result.html, '共 2 条')
assert.deepEqual(result.warnings, [])
})
test('同一个缺失路径出现多次:合并成一条警告并累加 count', () => {
const result = renderTemplate('{{ a }}-{{ a }}-{{ a }}', {})
assert.equal(result.html, '--')
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].count, 3)
})
test('默认转义:& < > " \' 全部实体化,中文原样', () => {
const value = `<script>alert("x&y")</script>'中文`
const result = renderTemplate('{{ v }}', { v: value })
assert.equal(result.html, '&lt;script&gt;alert(&quot;x&amp;y&quot;)&lt;/script&gt;&#39;中文')
assert.ok(!result.html.includes('<'), '转义后不该再出现 <')
})
test('三花括号与 {{& }} 原样输出(设计稿 §3.4)', () => {
const data = { logoImg: '<img src="/uploads/a.png" alt="">' }
assert.equal(renderTemplate('{{{ logoImg }}}', data).html, '<img src="/uploads/a.png" alt="">')
assert.equal(renderTemplate('{{& logoImg }}', data).html, '<img src="/uploads/a.png" alt="">')
// 默认通道仍然转义,两者不能混为一谈
assert.equal(renderTemplate('{{ logoImg }}', data).html, '&lt;img src=&quot;/uploads/a.png&quot; alt=&quot;&quot;&gt;')
})
test('数字与布尔照原样渲染', () => {
assert.equal(renderTemplate('{{ n }}', { n: 0 }).html, '0')
assert.equal(renderTemplate('{{ n }}', { n: -1.5 }).html, '-1.5')
assert.equal(renderTemplate('{{ b }}', { b: true }).html, 'true')
assert.equal(renderTemplate('{{ b }}', { b: false }).html, 'false')
})
test('对象/数组插值:空串 + not-scalar 警告,不把 [object Object] 泼到屏上', () => {
const objectCase = renderTemplate('{{ settings }}', { settings: { a: 1 } })
assert.equal(objectCase.html, '')
assert.equal(objectCase.warnings[0].code, 'not-scalar')
const arrayCase = renderTemplate('{{ rows }}', { rows: [1, 2] })
assert.equal(arrayCase.html, '')
assert.equal(arrayCase.warnings[0].code, 'not-scalar')
})
test('不支持的写法(运算符/过滤器/空标签):空串 + invalid-path 警告,不抛异常', () => {
const cases = ['{{ a + 1 }}', '{{ a | b }}', '{{ a.b. }}', '{{ }}', '{{ @foo }}', '{{ a/b }}']
for (const source of cases) {
const result = renderTemplate(source, { a: 1, b: 2 })
assert.equal(result.html, '', source)
assert.equal(result.error, null, source)
assert.equal(result.warnings[0]?.code, 'invalid-path', source)
}
})
test('渲染不抛异常:各种奇怪的数据对象都只是空串 + 警告', () => {
const sources = ['{{ a.b }}', '{{ a }}', '{{ @index }}', '{{ ../a }}', '{{#if a}}{{ a }}{{/if}}']
const datas: unknown[] = [undefined, null, 0, 'str', [], { a: null }, { a: () => 1 }]
for (const source of sources) {
for (const data of datas) {
assert.doesNotThrow(() => renderTemplate(source, data), `${source} / ${String(data)}`)
}
}
})
+47 -1
View File
@@ -8,7 +8,10 @@ import type {
AthleteInput,
AthleteStanding,
AuditLog,
AuditLogQuery,
BackupResult,
BackupRestoreInput,
BackupRestoreResult,
CurrentUser,
DisplayCommand,
EntryHeatPayload,
@@ -215,6 +218,13 @@ export interface AthleteBatchResult {
}
export interface TeamsApi extends CrudApi<Team, TeamInput> {
/**
* 单位列表。与 CrudApi.list 的差别只有 includeDeleted 一个开关。
*
* `includeDeleted: true` 时连「彻底删除」(对界面不可见)的单位一并返回,供管理端
* 把它们翻出来恢复;服务端只放 admin 用这个开关,其余角色 403。
*/
list(query?: TeamsListQuery): Promise<Team[]>
/**
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
*
@@ -223,6 +233,25 @@ export interface TeamsApi extends CrudApi<Team, TeamInput> {
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
*/
purge(id: number): Promise<void>
/**
* 恢复被彻底删除的单位(`POST /api/teams/{id}/restore`)。
*
* 只是把「不可见」的标记撤掉:报名、成绩与积分一行没动,所以不会重算、也不会推榜单。
* 幂等——单位本来就没被隐藏时照样 200,`restored` 为 false;单位不存在时 404。
*/
restore(id: number): Promise<TeamRestoreResult>
}
/** 单位列表的查询开关。 */
export interface TeamsListQuery {
/** true 时连「彻底删除」的单位一并返回(仅 admin)。 */
includeDeleted?: boolean
}
/** 恢复被彻底删除单位的结果;restored 为 false 表示它本来就没被隐藏(幂等命中)。 */
export interface TeamRestoreResult {
team: Team
restored: boolean
}
export interface EntriesApi {
@@ -321,7 +350,7 @@ export interface ExportApi {
}
export interface AuditApi {
list(query?: PageQuery & { entityType?: string; userId?: number }): Promise<Page<AuditLog>>
list(query?: AuditLogQuery): Promise<Page<AuditLog>>
}
export interface RulesApi {
@@ -333,6 +362,14 @@ export interface RulesApi {
export interface SystemApi {
backup(): Promise<BackupResult>
/**
* 用一份备份恢复当前库(`POST /api/backup/restore`,仅 admin)。
*
* 这是停机动作:服务端会先留一份恢复前安全副本,再换掉库文件,然后优雅退出、
* 由 Console 拉起。调用方拿到 `restarting: true` 后必须轮询健康检查等服务端回来,
* 不能像普通写操作那样立刻认为已经生效(与切换赛事同一套等待逻辑)。
*/
restore(input: BackupRestoreInput): Promise<BackupRestoreResult>
}
/**
@@ -429,8 +466,15 @@ function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBat
function createTeamsApi(http: Http): TeamsApi {
return {
...createCrudApi<Team, TeamInput>(http, '/teams'),
// 默认列表不带查询串(服务端对非 admin 的 includeDeleted 回 403,不能默认带上)。
list: (query) =>
http.request<Team[]>('/teams', {
query: query?.includeDeleted === true ? { includeDeleted: 1 } : undefined,
}),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。
purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }),
// 恢复是写操作:服务端按 X-Request-Id 幂等回放,重复点也只会生效一次。
restore: (id) => http.request<TeamRestoreResult>(`/teams/${id}/restore`, { method: 'POST' }),
}
}
@@ -743,6 +787,8 @@ export class ApiClient {
this.rules = createRulesApi(http)
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
// 恢复要走幂等(X-Request-Id):现场断网重试时,重复请求不能把库换两遍。
restore: (input) => http.request<BackupRestoreResult>('/backup/restore', { method: 'POST', body: input }),
}
this.projects = createProjectsApi(http)
+72
View File
@@ -39,3 +39,75 @@ test('401 AUTH_REQUIRED 触发 onUnauthorized', async () => {
})
assert.equal(called, 1)
})
// 恢复是停机动作:客户端只把 file + kind 原样发给 /api/backup/restore,
// 拿到 restarting 回执后由页面去等服务端回来(不能在这里就当成已生效)。
// kind 传的是「要覆盖的那份库」(列表里的 dbKind),安全副本也照此传。
test('system.restore 提交 file + kind 并返回回执', async () => {
let url = ''
let method: string | undefined
let body: Record<string, unknown> = {}
const client = new ApiClient({
fetchImpl: async (input, init) => {
url = String(input)
method = init?.method
body = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(
JSON.stringify({
ok: true,
data: {
kind: 'safety',
dbKind: 'event',
kindLabel: '赛事库',
file: 'pre-restore-event-20260911-120000.db',
target: '/data/event_projs/default/event.db',
safetyCopy: 'pre-restore-event-20260911-130000.db',
safetyCopyPath: '/data/backups/pre-restore-event-20260911-130000.db',
prunedSafetyCopies: 2,
restarting: true,
message: '已用 pre-restore-event-20260911-120000.db 恢复赛事库。',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
},
})
const result = await client.system.restore({
file: 'pre-restore-event-20260911-120000.db',
kind: 'event',
})
assert.equal(url, '/api/backup/restore')
assert.equal(method, 'POST')
assert.equal(body.file, 'pre-restore-event-20260911-120000.db')
assert.equal(body.kind, 'event')
assert.equal(result.restarting, true)
assert.equal(result.kind, 'safety')
assert.equal(result.dbKind, 'event')
assert.equal(result.safetyCopy, 'pre-restore-event-20260911-130000.db')
assert.equal(result.prunedSafetyCopies, 2)
})
// 坏文件 / 选错库都是服务端的真实回答(400),必须原样抛给操作员,
// 不能降级成「已恢复」。
test('system.restore 把 400 原样抛出', async () => {
const client = new ApiClient({
fetchImpl: async () =>
new Response(
JSON.stringify({
ok: false,
error: { code: 'VALIDATION_FAILED', message: '备份文件不可用:缺少关键表 teams' },
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
})
await assert.rejects(client.system.restore({ file: 'event-x.db', kind: 'event' }), (error: unknown) => {
return (
error instanceof ApiError &&
error.code === 'VALIDATION_FAILED' &&
error.message.includes('备份文件不可用')
)
})
})
+49
View File
@@ -17,6 +17,11 @@ export interface Team {
color: string | null
sortOrder: number
active: boolean
/**
* 非空表示该单位已被「彻底删除」(假删除:默认列表不返回它,报名与成绩一行没动);
* 只有 admin 用 `list({ includeDeleted: true })` 才拿得到这类单位,用来恢复它们。
*/
deletedAt?: string | null
}
export type TeamInput = Omit<Team, 'id'>
@@ -273,6 +278,7 @@ export interface DraftResultInput {
unit?: string
isTie?: boolean
status?: ResultStatus
/** 服务端已拒绝非空值(400):引擎不读它,加减分请走「积分调整」。 */
pointsOverride?: number | null
note?: string | null
}
@@ -515,6 +521,42 @@ export interface BackupResult {
createdAt: string
}
/**
* 恢复请求体(POST /api/backup/restore)。
*
* `file` 只接受备份目录里的裸文件名;`kind` 是**要覆盖的那份库**
* (取备份列表里的 `dbKind`)——安全副本的性质是 `safety`,但它同样属于某一份库,
* 拿它退回上一个时间点是正当操作,所以请求传的仍是 system / event。
*/
export interface BackupRestoreInput {
file: string
kind: 'system' | 'event'
}
/**
* 恢复被接受后的回执。
*
* `restarting` 为 true 表示服务端已经换好库文件、正在优雅退出,随后由 Console 拉起;
* `safetyCopy` 是恢复前的安全副本文件名,恢复选错时间点时用它退回。
*/
export interface BackupRestoreResult {
/** 这次用的文件的性质:system / event / safety(安全副本)。 */
kind: 'system' | 'event' | 'safety'
/** 被恢复的那份库。 */
dbKind: 'system' | 'event'
kindLabel: string
file: string
target: string
safetyCopy: string
safetyCopyPath: string
/** 本次恢复顺带清理掉的旧安全副本份数(每类库保留最近 5 份)。 */
prunedSafetyCopies: number
restarting: boolean
message: string
/** 非空表示恢复已经完成、但有话要说(目前只用于审计写入失败)。 */
warning?: string
}
/**
* 一项可选字体(GET /api/fonts)。
*
@@ -691,6 +733,13 @@ export interface PageQuery {
size?: number
}
/** 审计日志查询:action 为子串模糊匹配,三个过滤条件可组合。 */
export interface AuditLogQuery extends PageQuery {
entityType?: string
userId?: number
action?: string
}
export interface Page<T> {
items: T[]
total: number