fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)

## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
This commit is contained in:
AskaEth committed 2026-09-11 23:46:59 +08:00
1 parent db94d9c119
commit 6841cf49aa
147 files changed
+8141 -423

No files matched your search

+21 -3
View File
@@ -959,6 +959,12 @@ function buildDraftPayload(): DraftResultInput[] {
async function saveDraft(silent = false): Promise<boolean> {
if (selectedEventId.value === null) return false
if (saving.value) return false
if (data.isMock) {
// 与发布 / 批次 / 团体名次同一口径:示例数据模式下写接口没有后端可落库,
// 照发请求只会得到一句网络错误,操作员会以为是自己手滑而不是「这台机没连服务端」。
toast.error('示例数据模式不能保存草稿', '后端未就绪,草稿不会写入真实赛事')
return false
}
if (allPublished.value) {
// 可见行全部 published 后服务端会拒绝这次草稿(载荷为空),必须先撤销发布。
toast.error(`${scopeWord.value}已全部发布`, '修改前请先撤销发布')
@@ -1056,6 +1062,12 @@ async function confirmPublish(): Promise<void> {
async function confirmUnpublish(): Promise<void> {
if (selectedEventId.value === null) return
if (data.isMock) {
// 与发布同一口径:示例数据模式下 unpublish 只改内存,提示「已撤销发布」等于谎报。
toast.error('示例数据模式不能撤销发布', '后端未就绪,撤销发布不会写入真实赛事')
unpublishDialog.value = false
return
}
unpublishing.value = true
try {
await adminApi.unpublish({
@@ -1444,7 +1456,13 @@ function moveFocusBy(delta: number): void {
next?.focus()
}
/** 默认聚焦上次编辑的项目(07 第 7 节);URL 带 eventId 时优先。 */
/**
* 进入录入页时默认选中小项:URL 带 eventId 时优先,否则取第一个未开始(pending)的项目,
* 都没有就取列表第一个。
*
* 注意口径:这里**没有**「上次编辑的小项」这种记忆——前端从未持久化过录入位置,
* 所以换人换班后回到录入页看到的是兜底结论,不是他离开时的那个项目。
*/
async function pickInitialEvent(): Promise<void> {
if (selectedEventId.value !== null) return
const query = route.query.eventId
@@ -1486,7 +1504,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || saving || allPublished"
:disabled="!canWrite || saving || allPublished || data.isMock"
:title="allPublished ? `${scopeWord}已全部发布,请先撤销发布` : ''"
@click="saveDraft()"
>
@@ -1589,7 +1607,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
v-if="hasPublished"
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite"
:disabled="!canWrite || data.isMock"
@click="unpublishDialog = true"
>
撤销发布
+1 -1
View File
@@ -59,7 +59,7 @@ const EXPORT_OPTIONS: ReadonlyArray<ExportOption> = [
{
key: 'standings',
title: '总表',
desc: '总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿',
desc: '总积分榜 + 单位奖牌榜 + 个人积分奖牌榜,一个工作簿',
icon: 'standings',
},
{ key: 'all', title: '全部', desc: '总表 + 全部分项,多 sheet 工作簿,适合归档打印', icon: 'database' },
+6 -3
View File
@@ -14,7 +14,7 @@ import PageHeader from '../components/PageHeader.vue'
import RevisionGraph from '../components/history/RevisionGraph.vue'
import RevisionDetail from '../components/history/RevisionDetail.vue'
import { buildGraph, describeRow, diffSnapshots, laneColor } from '../components/history/graph'
import { fetchRevisionDetail, fetchRevisions } from '../components/history/revisionsSource'
import { fetchRevisionDetail, fetchRevisions, revisionLoadErrorText } from '../components/history/revisionsSource'
import { createMockRevisions } from '../components/history/mockRevisions'
import { ACTION_LABELS, type Revision, type RevisionDiffRow, type TeamImpact } from '../components/history/types'
import { adminApi } from '../api/client'
@@ -241,7 +241,8 @@ async function loadDetail(id: number): Promise<void> {
revisions.value = revisions.value.map((item) => (item.id === id ? { ...item, ...detail } : item))
detailLoadedIds.value = [...detailLoadedIds.value, id]
} catch (caught) {
detailError.value = caught instanceof Error ? caught.message : '加载变更详情失败'
// 与列表同一套口径:会话中途失效(403/401)也要说人话,别把状态码摔在脸上。
detailError.value = revisionLoadErrorText(caught)
} finally {
detailLoading.value = false
}
@@ -266,7 +267,9 @@ async function load(): Promise<void> {
if (!isUnavailable(caught)) {
revisions.value = []
usingMock.value = false
loadError.value = caught instanceof Error ? caught.message : '变更历史接口不可用'
// 权限边界(401/403)在这里换成人话:变更历史是 recorder 起的接口,
// 只读访客点进来不该看到裸的 HTTP 状态码。
loadError.value = revisionLoadErrorText(caught)
return
}
const all = createMockRevisions()
+3 -3
View File
@@ -14,7 +14,7 @@ type TabKey = 'points' | 'medals' | 'athletes'
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'points', label: '单位总分榜' },
{ key: 'medals', label: '团体奖牌榜' },
{ key: 'medals', label: '单位奖牌榜' },
{ key: 'athletes', label: '个人积分榜' },
]
@@ -117,7 +117,7 @@ async function exportTeamDetail(): Promise<void> {
}
}
/** 榜单级明细导出(单位总分 / 团体奖牌 / 个人积分),与单位明细共用同一个单飞锁。 */
/** 榜单级明细导出(单位总分 / 单位奖牌 / 个人积分),与单位明细共用同一个单飞锁。 */
async function exportBoard(
kind: 'teamAll' | 'medalAll' | 'athleteAll',
label: string,
@@ -275,7 +275,7 @@ async function refresh(): Promise<void> {
</tbody>
</table>
<!-- 团体奖牌榜 -->
<!-- 单位奖牌榜 -->
<table v-else-if="activeTab === 'medals'" class="mx-table">
<thead>
<tr>
+5 -2
View File
@@ -235,10 +235,13 @@ function apiError(code: unknown, message: string, status: number, detail?: unkno
* 以为已经退赛 / 删干净了,页面必须先看 data.isMock 再决定是否调用这里。
*/
export async function submitEntryBatch(payload: EntryBatchPayload): Promise<EntryBatchResult> {
// 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。
// 两处各生成一个就永远命中不了缓存,超时重试「移出报名」会真的把同一批人删两遍。
const requestId = payload.requestId ?? defaultRequestId()
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'X-Request-Id': defaultRequestId(),
'X-Request-Id': requestId,
}
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
@@ -249,7 +252,7 @@ export async function submitEntryBatch(payload: EntryBatchPayload): Promise<Entr
method: 'POST',
headers,
credentials: 'include',
body: JSON.stringify({ ...payload, requestId: payload.requestId ?? defaultRequestId() }),
body: JSON.stringify({ ...payload, requestId }),
})
} catch (caught) {
throw apiError('NETWORK', '无法连接服务端,批量操作没有生效', 0, caught)