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:
1 parent
db94d9c119
commit
6841cf49aa
147 files changed
+8141
-423
No files matched your search
@@ -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"
|
||||
>
|
||||
撤销发布
|
||||
|
||||
@@ -59,7 +59,7 @@ const EXPORT_OPTIONS: ReadonlyArray<ExportOption> = [
|
||||
{
|
||||
key: 'standings',
|
||||
title: '总表',
|
||||
desc: '总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿',
|
||||
desc: '总积分榜 + 单位奖牌榜 + 个人积分奖牌榜,一个工作簿',
|
||||
icon: 'standings',
|
||||
},
|
||||
{ key: 'all', title: '全部', desc: '总表 + 全部分项,多 sheet 工作簿,适合归档打印', icon: 'database' },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user