feat: 团体奖牌 / 个人积分两个榜单的专属明细导出

用户答复「允许细化」——给另外两个榜单各做一份逐项来源导出(此前它们的「明细」共用
单位积分明细、个人榜连明细列都没有)。

- `GET /api/export/medals.xlsx` → `团体奖牌明细.xlsx`:总表 单位/大项/小项/组别/名次/奖牌/运动员-队
  + 每个有奖牌的单位一张分表(末尾小计 金X/银Y/铜Z);无奖牌的单位不出分表。
  去重与牌色判定**完全交给 domain**(每组调 domain.BuildMedalStandings),导出侧只做
  「同一报名项归一组」,不复制任何计奖规则——双计通道只记一枚奖牌因此自动成立。
- `GET /api/export/athletes.xlsx` → `个人积分总明细.xlsx`:总表 单位/运动员/大项/小项/组别/
  名次/成绩/得分/来源 + 每个有得分的单位一张分表(按单位分,不按人分)。
  得分取**记到个人的原始分**(与个人积分榜同一口径,不看 count_to_team、不乘折算系数)。
- 管理端三个榜单的卡片头各一个导出入口(导出总明细 / 导出奖牌明细 / 导出个人明细),
  展开单位时明细卡头是「导出该单位明细」;导出页仍是 5 项,两个新导出只在积分榜页触发。

护栏(跨端点一致性,都有实测断言):
- 奖牌明细逐单位与「团体奖牌榜」对齐,双计项目只出一行;
- 个人明细合计与「个人积分榜」一致,且把折算系数改成 0.5 后个人明细一分不变、单位总分变 5。

测试:服务端 12 包全绿(358 个测试函数);前端 vue-tsc 通过、190 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q72,含上一轮"个人积分榜不能导出"的修正)。
This commit is contained in:
AskaEth committed 2026-09-11 01:17:14 +08:00
1 parent 782a38f82f
commit 8e707e1e9b
15 files changed
+1546 -45

No files matched your search

+14 -1
View File
@@ -516,7 +516,15 @@ export const backupsApi = {
// 结果导出(xlsx)
// ---------------------------------------------------------------------------
export type ResultExportKind = 'standings' | 'all' | 'allFlat' | 'event' | 'team' | 'teamAll'
export type ResultExportKind =
| 'standings'
| 'all'
| 'allFlat'
| 'event'
| 'team'
| 'teamAll'
| 'medalAll'
| 'athleteAll'
export interface ResultExportRequest {
kind: ResultExportKind
@@ -536,6 +544,8 @@ const RESULT_EXPORT_FILENAME: Record<ResultExportKind, string> = {
event: '分项成绩表.xlsx',
team: '单位积分明细.xlsx',
teamAll: '单位积分总明细.xlsx',
medalAll: '团体奖牌明细.xlsx',
athleteAll: '个人积分总明细.xlsx',
}
function mockDownload(request: ResultExportRequest, context: string): DownloadFile {
@@ -575,6 +585,9 @@ export const resultsExportApi = {
}
// 总明细:服务端把「单位」列加在最前,再给每个有明细行的单位附一张分表。
if (request.kind === 'teamAll') path = '/export/teams.xlsx'
// 奖牌 / 个人明细同样是「总表 + 每个单位一张分表」,都不需要额外参数。
if (request.kind === 'medalAll') path = '/export/medals.xlsx'
if (request.kind === 'athleteAll') path = '/export/athletes.xlsx'
const blob = await requestBlob(path, { timeoutMs: 60_000 })
return { blob, filename: RESULT_EXPORT_FILENAME[request.kind] }
},
+49 -20
View File
@@ -27,8 +27,8 @@ const expandedTeamId = ref<number | null>(null)
const breakdowns = ref<Record<number, TeamBreakdown>>({})
const loadingBreakdown = ref(false)
const pushing = ref(false)
/** 正在进行的导出任务;非空时两个导出按钮都禁用,避免重复触发下载。 */
const exporting = ref<'team' | 'teamAll' | null>(null)
/** 正在进行的导出任务;非空时全部导出按钮都禁用,避免重复触发下载。 */
const exporting = ref<'team' | 'teamAll' | 'medalAll' | 'athleteAll' | null>(null)
const closeDialog = ref(false)
const search = ref('')
@@ -117,15 +117,20 @@ async function exportTeamDetail(): Promise<void> {
}
}
async function exportAllTeams(): Promise<void> {
/** 榜单级明细导出(单位总分 / 团体奖牌 / 个人积分),与单位明细共用同一个单飞锁。 */
async function exportBoard(
kind: 'teamAll' | 'medalAll' | 'athleteAll',
label: string,
): Promise<void> {
if (exporting.value !== null) return
exporting.value = 'teamAll'
exporting.value = kind
try {
const result = await resultsExportApi.download({ kind: 'teamAll' })
const result = await resultsExportApi.download({ kind })
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
} catch (caught) {
toast.error('导出总明细失败', describeError(caught))
// 服务端原文(无权限 / 生成失败 / 连不上)原样带出,不吞成笼统的「导出失败」。
toast.error(`${label}失败`, describeError(caught))
} finally {
exporting.value = null
}
@@ -157,15 +162,6 @@ async function refresh(): Promise<void> {
<button class="mx-btn mx-btn-secondary" type="button" @click="refresh">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="exporting !== null"
@click="exportAllTeams"
>
<MxIcon name="export" :size="15" />
{{ exporting === 'teamAll' ? '导出中…' : '导出总明细' }}
</button>
<button class="mx-btn mx-btn-ghost" type="button" @click="closeDialog = true">
<MxIcon name="close" :size="15" /> 关闭大屏明细
</button>
@@ -195,8 +191,41 @@ async function refresh(): Promise<void> {
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">{{ TABS.find((tab) => tab.key === activeTab)?.label }}</span>
<span class="mx-badge mx-badge-info">
更新于 {{ formatDateTime(data.lastRefreshAt) }}
<span class="card-actions">
<span class="mx-badge mx-badge-info">
更新于 {{ formatDateTime(data.lastRefreshAt) }}
</span>
<!-- 每个榜单各有自己的明细导出,按钮跟着当前页签切换。 -->
<button
v-if="activeTab === 'points'"
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="exporting !== null"
@click="exportBoard('teamAll', '导出总明细')"
>
<MxIcon name="export" :size="14" />
{{ exporting === 'teamAll' ? '导出中…' : '导出总明细' }}
</button>
<button
v-else-if="activeTab === 'medals'"
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="exporting !== null"
@click="exportBoard('medalAll', '导出奖牌明细')"
>
<MxIcon name="export" :size="14" />
{{ exporting === 'medalAll' ? '导出中…' : '导出奖牌明细' }}
</button>
<button
v-else
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="exporting !== null"
@click="exportBoard('athleteAll', '导出个人明细')"
>
<MxIcon name="export" :size="14" />
{{ exporting === 'athleteAll' ? '导出中…' : '导出个人明细' }}
</button>
</span>
</div>
@@ -317,7 +346,7 @@ async function refresh(): Promise<void> {
<span class="mx-card-title">
{{ activeBreakdown === null ? '单位积分明细' : `${activeBreakdown.teamName} · 明细` }}
</span>
<span class="detail-actions">
<span class="card-actions">
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
@@ -450,8 +479,8 @@ tr.expanded {
position: sticky;
top: 0;
}
/* 明细卡头部要同时放「导出」与「推送」两个按钮,超出时换行而不是撑破卡片。 */
.detail-actions {
/* 卡片头 / 明细卡头部的操作组:更新徽标 + 导出(+ 推送),放不下时换行而不是撑破卡片。 */
.card-actions {
display: flex;
align-items: center;
gap: 8px;
@@ -144,3 +144,79 @@ test('连不上服务端时降级为占位文件,网络错误留在 mockReason
assert.equal(mockReason.value, '无法连接服务端')
assert.equal(lastRequest.url, '/api/export/teams.xlsx')
})
test('奖牌明细导出走 GET /api/export/medals.xlsx,文件名是「团体奖牌明细.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await resultsExportApi.download({ kind: 'medalAll' })
assert.equal(lastRequest.url, '/api/export/medals.xlsx')
assert.equal(lastRequest.method, 'GET')
assert.equal(result.data.filename, '团体奖牌明细.xlsx')
assert.equal(result.mock, false, '正常响应不应被当成示例数据')
})
test('个人明细导出走 GET /api/export/athletes.xlsx,文件名是「个人积分总明细.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await resultsExportApi.download({ kind: 'athleteAll' })
assert.equal(lastRequest.url, '/api/export/athletes.xlsx')
assert.equal(lastRequest.method, 'GET')
assert.equal(result.data.filename, '个人积分总明细.xlsx')
})
// 整榜导出不需要 teamId;一旦误落进 team 分支就会变成 /export/teams/undefined。
test('medalAll / athleteAll 不被 team 分支截胡,也不要求 teamId', async () => {
respondWith(() => xlsxResponse())
await resultsExportApi.download({ kind: 'medalAll' })
assert.equal(lastRequest.url, '/api/export/medals.xlsx')
respondWith(() => xlsxResponse())
await resultsExportApi.download({ kind: 'athleteAll' })
assert.equal(lastRequest.url, '/api/export/athletes.xlsx')
})
test('奖牌明细失败时服务端原文原样抛出,不吞成笼统的「导出失败」', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'INTERNAL', message: '奖牌明细生成失败:event.db 已锁定' } }, 500),
)
await assert.rejects(
() => resultsExportApi.download({ kind: 'medalAll' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 500)
assert.equal(error.message, '奖牌明细生成失败:event.db 已锁定')
return true
},
)
})
test('个人明细失败时 403 原文原样抛出', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '需要裁判长权限' } }, 403),
)
await assert.rejects(
() => resultsExportApi.download({ kind: 'athleteAll' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 403)
assert.equal(error.message, '需要裁判长权限')
return true
},
)
})
// 占位文件名也要跟着 kind 走,否则连不上时会存成一个叫「单位积分总明细(示例数据).txt」的奖牌文件。
test('连不上时新 kind 的占位文件名也正确', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await resultsExportApi.download({ kind: 'medalAll' })
assert.equal(result.mock, true)
assert.equal(result.data.filename, '团体奖牌明细(示例数据).txt')
})