feat(export): 新增「导入格式」报名名单导出(汇总 / 分小项),与导入模板逐列一致、可直接重导;版本 dev_0.5.14

主人反馈:导出页「报名名单」卡片导出的表格与导入模板对不上,没法拿回去重导。

核对属实(旧导出是给人看的报表):列序与 importColumns 不同、缺「种子 / 队员名单 / 队伍」
三列、姓名列被拼成「姓名(号码布)」、队名拼在单位名后面、状态列写「已退赛」而
normalizeImportStatus 不认 —— 整体错位 + 状态列报错,重导必炸。

- 新增 GET /api/export/entries-import.xlsx?format=flat|sheets&lang=zh|en&<四个筛选>
  · flat(缺省)= 所有行汇总在一张工作表;表名不含 @(导入侧见到带 @ 的表名就整份按
    分工作表模板解析,汇总表定不出「小项@组别」会被拒)
  · sheets = 按「小项@组别」分工作表,表名走 entrySheetName(与模板、导入解析三处同源,
    31 字符截断与撞名去重都跟着一起走)
  · 权限与筛选参数与 /api/export/entries.xlsx 完全一致(访客可读);空结果回 200 + 只有表头
  · 列序不写常量:按 importColumns 逐列取值,表头走 entryTemplateHeader(lang),
    加列/改序时测试当场红(TestEntryImportRecordCoversImportColumns)
- 取值口径(重导的关键):团体行的「姓名」留空、队员写「队员名单」列(中文逗号分隔;
  以库里 token 为底,再补上 entry_athletes 里后加的队员号码布,只增不减)、
  队伍独立成列、状态写导入器认的四个中文说法(正常参赛/退赛/取消资格/未出场)、
  联队照旧写 A+B、未分批留空、证件号/性别/年龄来自运动员档案(团体行空)
- 新增 internal/export/entries_import.go(读库层,与报表导出同源 SQL、取值口径刻意不同)
  与 internal/httpapi/entries_import_export.go(工作簿组装 + handler)
- 原有报表导出(/api/export/entries.xlsx 与 internal/export/entries.go)一个字没动;
  导出页新增两个按钮与中文/英文表头选择(只影响这两个导入格式)

测试:往返测试把导出件直接喂回报名导入解析器,flat 与 sheets 各断言四条(解析零错误 /
行数与 13 列逐项一致 / 团体行没被变成个人报名 / 两种格式都跑);另做变异验证 —— 状态改回
「已退赛」、给团体行填姓名,守卫当场变红。四套自检 ×2 全绿(finalcheck_0514.log:
前端 996 例 / Console 96 例 / 服务端 16 包,EXIT=0)。

无数据库迁移;已有成绩、名次与榜单不变。
文档:使用手册 §11.1(并订正「不要拿导出件重导」的旧话)、报名名单导入规范 新增第九节、
升级说明 新增 dev_0.5.14、常见问题 新增 Q104。
This commit is contained in:
AskaEth committed 2026-09-17 04:03:52 +08:00
1 parent 19e5630b39
commit ee19b92182
19 files changed
+2135 -34

No files matched your search

+104 -16
View File
@@ -23,10 +23,22 @@ import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
import {
downloadBaseDataXLSX,
downloadEntriesImportXLSX,
downloadEntriesXLSX,
importBaseDataXLSX,
type BaseDataImportResult,
type EntryExportFilter,
} from '@/shared/basedata'
import {
ENTRIES_IMPORT_ACTIONS,
ENTRIES_IMPORT_LANG_NOTE,
ENTRIES_IMPORT_LANG_OPTIONS,
ENTRIES_IMPORT_NOTE,
entriesImportRequest,
parseEntryFilter,
type EntriesImportFormat,
type EntriesImportLanguage,
} from './exportEntriesImport'
import type { EventCategory } from '@/shared'
/**
@@ -135,6 +147,10 @@ const entryTeamId = ref<number | null>(null)
const entryHeat = ref('')
const entryCategories = ref<EventCategory[]>([])
const entryExporting = ref(false)
/** 正在导出的那张导入格式表;null = 两个导入格式按钮都没在跑。 */
const entryImportExporting = ref<EntriesImportFormat | null>(null)
/** 两个导入格式导出的表头语言,默认中文;报表导出没有这个参数。 */
const entryImportLang = ref<EntriesImportLanguage>('zh')
/**
* 换小项就把组别重置并重新拉取:组别 id 只在小项内有意义,
@@ -151,25 +167,27 @@ watch(entryEventId, async (id) => {
}
})
async function exportEntries(): Promise<void> {
let heat: number | undefined
const rawHeat = entryHeat.value.trim()
if (rawHeat !== '') {
const parsed = Number(rawHeat)
if (!Number.isInteger(parsed) || parsed <= 0) {
toast.warning('批次需要是正整数(留空表示不筛)')
return
}
heat = parsed
/** 三个导出按钮共用同一套筛选与批次校验;批次写错时提示后返回 null(这条不发出去)。 */
function entryFilterOrWarn(): EntryExportFilter | null {
const parsed = parseEntryFilter({
eventId: entryEventId.value,
categoryId: entryCategoryId.value,
teamId: entryTeamId.value,
heat: entryHeat.value,
})
if (!parsed.ok) {
toast.warning(parsed.message)
return null
}
return parsed.filter
}
async function exportEntries(): Promise<void> {
const filter = entryFilterOrWarn()
if (filter === null) return
entryExporting.value = true
try {
const file = await downloadEntriesXLSX({
eventId: entryEventId.value ?? undefined,
categoryId: entryCategoryId.value ?? undefined,
teamId: entryTeamId.value ?? undefined,
heat,
})
const file = await downloadEntriesXLSX(filter)
adminApi.client.exports.save(file.blob, file.filename)
toast.success(`已导出 ${file.filename}`)
} catch (caught) {
@@ -179,6 +197,30 @@ async function exportEntries(): Promise<void> {
}
}
/**
* 导出「导入格式」的报名名单:与报表同一个筛选,只多带 format / lang。
*
* 口径与报表导出一样是只读下载(访客也能用),成功 / 失败的提示也同一套;
* 只是产出的列与导入模板逐列一致,能直接拿去重新导入。
*/
async function exportEntriesImport(format: EntriesImportFormat): Promise<void> {
if (entryImportExporting.value !== null) return
const filter = entryFilterOrWarn()
if (filter === null) return
entryImportExporting.value = format
try {
const file = await downloadEntriesImportXLSX(
entriesImportRequest(filter, { format, lang: entryImportLang.value }),
)
adminApi.client.exports.save(file.blob, file.filename)
toast.success(`已导出 ${file.filename}`, '列与报名导入模板逐列一致,可直接拿去重新导入')
} catch (caught) {
toast.error('导出失败', describeError(caught))
} finally {
entryImportExporting.value = null
}
}
// ── 基础数据包(单位 / 大项 / 小项 / 组别) ──────────────────────────────────
const baseDataFile = ref<File | null>(null)
const baseDataInput = ref<HTMLInputElement | null>(null)
@@ -699,6 +741,38 @@ async function applyRules(): Promise<void> {
<MxIcon name="export" :size="15" />
{{ entryExporting ? '导出中…' : '导出报名名单' }}
</button>
<!-- 导入格式:与上面的报表是两种口径,用一条分隔线隔开,语言选择只作用于这一块 -->
<div class="entries-import-block">
<p class="hint">{{ ENTRIES_IMPORT_NOTE }}</p>
<div class="lang-row">
<span class="mx-label">表头语言</span>
<button
v-for="option in ENTRIES_IMPORT_LANG_OPTIONS"
:key="option.lang"
class="mx-btn mx-btn-sm"
:class="entryImportLang === option.lang ? 'mx-btn-primary' : 'mx-btn-secondary'"
type="button"
:title="option.title"
@click="entryImportLang = option.lang"
>
{{ option.label }}
</button>
</div>
<p class="hint">{{ ENTRIES_IMPORT_LANG_NOTE }}</p>
<button
v-for="action in ENTRIES_IMPORT_ACTIONS"
:key="action.format"
class="mx-btn mx-btn-secondary full"
type="button"
:title="action.title"
:disabled="entryImportExporting !== null"
@click="exportEntriesImport(action.format)"
>
<MxIcon name="export" :size="15" />
{{ entryImportExporting === action.format ? '导出中…' : action.label }}
</button>
</div>
</div>
</section>
@@ -1245,6 +1319,20 @@ async function applyRules(): Promise<void> {
.entries-card {
margin-top: 16px;
}
/* 导入格式与上面的报表是两种口径:用一条分隔线 + 一段说明把两处按钮分开。 */
.entries-import-block {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--mx-border);
}
.lang-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
/* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */
.basedata-grid {
display: grid;
+132
View File
@@ -0,0 +1,132 @@
/**
* 导出页「报名名单」卡片上三个导出按钮共用的纯逻辑与文案。
*
* 抽出来的原因:`.vue` 跑不进 `node --test`,而这张卡片最容易出错的两处恰恰是纯逻辑——
* 「四个筛选只在该维度选了 / 填了才拼进 query」(批次 0 / 空串 / 没填都不算筛)
* 与「表头语言只作用于两个导入格式的导出,报表那条调用不许带 lang」。
*
* 三种导出同一套筛选、同一句批次提示,只是口径不同:
* - 报表(GET /export/entries.xlsx):给人看,含小项名称、单位名称、备注;
* - 导入格式(GET /export/entries-import.xlsx):13 列与报名导入模板逐列一致,可重新导入,
* 分 flat(所有行汇总一张表)与 sheets(按「小项名称@组别名称」分工作表)两种。
*/
import type {
EntriesImportFormat,
EntriesImportLanguage,
EntriesImportRequest,
EntryExportFilter,
} from '@/shared/basedata'
export type { EntriesImportFormat, EntriesImportLanguage, EntriesImportRequest, EntryExportFilter }
/** 卡片上的筛选状态:下拉未选是 null,批次是输入框原文(空串 = 不筛)。 */
export interface EntryFilterState {
eventId: number | null
categoryId: number | null
teamId: number | null
heat: string
}
/** 批次写错时的提示(三个按钮共用;现有报表导出用的是同一句话)。 */
export const ENTRY_HEAT_MESSAGE = '批次需要是正整数(留空表示不筛)'
/** 解析结果:要么给出可用的筛选条件,要么给出要给用户看的那句话。 */
export type EntryFilterParse =
| { ok: true; filter: EntryExportFilter }
| { ok: false; message: string }
/**
* 页面筛选 → 请求参数。
*
* 只有真的选了 / 填了才带这个维度:下拉没选(null)与批次留空(空串)都让这个键整个不出现
* (= 不筛);批次必须是非零正整数,写错就整条不发(由调用方提示),免得把「批次」筛成一张空表。
*/
export function parseEntryFilter(state: EntryFilterState): EntryFilterParse {
const rawHeat = state.heat.trim()
let heat: number | undefined
if (rawHeat !== '') {
const parsed = Number(rawHeat)
if (!Number.isInteger(parsed) || parsed <= 0) return { ok: false, message: ENTRY_HEAT_MESSAGE }
heat = parsed
}
// 没值的维度整个键都不出现(而不是留一个 undefined):拼 query 时不必再判断一次。
const filter: EntryExportFilter = {}
if (state.eventId !== null) filter.eventId = state.eventId
if (state.categoryId !== null) filter.categoryId = state.categoryId
if (state.teamId !== null) filter.teamId = state.teamId
if (heat !== undefined) filter.heat = heat
return { ok: true, filter }
}
/**
* 在报表的筛选条件上补 format / lang,得到导入格式导出的请求参数。
*
* 两个导出走的是同一份 filter(同一个筛选、同一个批次口径),报表那条永远不带 lang:
* 表头语言是导入格式专有的参数,服务端的 entries.xlsx 根本不认它。
*/
export function entriesImportRequest(
filter: EntryExportFilter,
options: { format?: EntriesImportFormat; lang?: EntriesImportLanguage } = {},
): EntriesImportRequest {
// 原样带上 filter 里出现过的维度,再补 format / lang —— 两个导出共用同一份筛选。
return {
...filter,
format: options.format ?? 'flat',
lang: options.lang ?? 'zh',
}
}
export interface EntriesImportAction {
format: EntriesImportFormat
/** 按钮上的字。 */
label: string
/** 悬停说明:既要说清「能拿去重新导入」,也要和上面的报表按钮分得开。 */
title: string
}
/** 两个导入格式导出的按钮。措辞与基础数据页的模板下载(中文表头 / 英文表头)同一套口径。 */
export const ENTRIES_IMPORT_ACTIONS: readonly EntriesImportAction[] = [
{
format: 'flat',
label: '导出(汇总·导入格式)',
title:
'汇总·导入格式:所有行放在一张工作表里,列与报名导入模板逐列一致(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍 共 13 列),文件名「报名名单(导入格式).xlsx」,可以直接拿去重新导入。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。',
},
{
format: 'sheets',
label: '导出(分小项·导入格式)',
title:
'分小项·导入格式:按「小项名称@组别名称」分成多张工作表,每张表的列与报名导入模板逐列一致(共 13 列),文件名「报名名单(导入格式·分小项).xlsx」,整份可以直接拿去重新导入;小项与组别多的时候,一个小项组别一张表核对起来不容易串行。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。',
},
]
export interface EntriesImportLangOption {
lang: EntriesImportLanguage
label: string
title: string
}
/** 表头语言选项,默认中文;与模板下载的两个按钮同一套措辞。 */
export const ENTRIES_IMPORT_LANG_OPTIONS: readonly EntriesImportLangOption[] = [
{
lang: 'zh',
label: '中文表头',
title:
'两个导入格式导出的表头用中文列名(项目编码 / 组别 / 单位编码…);只影响下面两个导入格式的导出,上面的「导出报名名单」是报表,没有这个参数。',
},
{
lang: 'en',
label: '英文表头',
title:
'两个导入格式导出的表头用英文规范列名(event_code / category / team_code…);只影响下面两个导入格式的导出,上面的「导出报名名单」是报表,没有这个参数。',
},
]
/** 卡片正文里对「报表 / 导入格式」两种口径的说明。 */
export const ENTRIES_IMPORT_NOTE =
'下面两个按钮导出的是「导入格式」:13 列与报名导入模板逐列一致(项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单 / 队伍),导出的文件可以直接拿去重新导入。上面的「导出报名名单」是给人看的报表,另含小项名称、单位名称与备注,不能直接导回。'
/** 「表头语言」那一行的说明:说清它管谁、不管谁。 */
export const ENTRIES_IMPORT_LANG_NOTE =
'表头语言只影响下面两个「导入格式」的导出,默认中文;上面的「导出报名名单」是给人看的报表,没有这个参数,改这里不会动它。'
@@ -0,0 +1,243 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import {
downloadEntriesImportXLSX,
entriesExportPath,
entriesImportPath,
entriesImportXlsxFilename,
ENTRIES_IMPORT_SHEETS_XLSX_FILENAME,
ENTRIES_IMPORT_XLSX_FILENAME,
type DownloadFile,
} from '@/shared/basedata'
import {
ENTRIES_IMPORT_ACTIONS,
ENTRIES_IMPORT_LANG_NOTE,
ENTRIES_IMPORT_LANG_OPTIONS,
ENTRIES_IMPORT_NOTE,
ENTRY_HEAT_MESSAGE,
entriesImportRequest,
parseEntryFilter,
type EntryFilterState,
} from '../pages/exportEntriesImport'
/**
* 「导入格式」的报名名单导出(GET /api/export/entries-import.xlsx)在前端这一侧的四件事:
* 请求路径与筛选参数、两种格式各自的文件名、按钮 / 说明文案、以及「表头语言只作用于
* 两个导入格式、报表那条调用不带 lang」这条口径。
*
* 现场会踩的坑:筛了却导出全量(参数没拼上)、把 0 / 空串当批次发出去(筛成一张空表)、
* 点错按钮拿到一份不能导回的报表(文件名与文案分不清)。
* 服务端那一半(工作簿怎么生成)由另一个代理实现,这里只钉前端发出去的请求。
*/
interface Captured {
url?: string
method?: string
}
function xlsxResponse(): Response {
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
status: 200,
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
})
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => xlsxResponse()
let lastRequest: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET' }
return reply()
}) as typeof fetch
function respondWith(next: () => Response): void {
lastRequest = {}
reply = next
}
/** 页面上的筛选状态:什么都没选 / 没填。 */
function emptyState(): EntryFilterState {
return { eventId: null, categoryId: null, teamId: null, heat: '' }
}
// ── 参数拼装:缺省值、筛选、批次 ─────────────────────────────────────────────
test('导入格式缺省 flat + zh:两个参数总会出现在 query 里', () => {
assert.equal(entriesImportPath(), '/export/entries-import.xlsx?format=flat&lang=zh')
assert.equal(
entriesImportPath({ format: 'sheets', lang: 'en' }),
'/export/entries-import.xlsx?format=sheets&lang=en',
)
const request = entriesImportRequest({}, {})
assert.equal(request.format, 'flat')
assert.equal(request.lang, 'zh')
})
test('四个筛选只在有值时才拼进 query,顺序与报表导出一致', () => {
const parsed = parseEntryFilter({ eventId: 3, categoryId: 9, teamId: 1, heat: '2' })
assert.equal(parsed.ok, true)
assert.ok(parsed.ok)
assert.deepEqual(parsed.filter, { eventId: 3, categoryId: 9, teamId: 1, heat: 2 })
assert.equal(
entriesImportPath(entriesImportRequest(parsed.filter, { format: 'sheets', lang: 'zh' })),
'/export/entries-import.xlsx?format=sheets&lang=zh&eventId=3&categoryId=9&teamId=1&heat=2',
)
// 一个维度都没选:query 里只剩 format / lang,不能出现 eventId= 这类空参数。
const empty = parseEntryFilter(emptyState())
assert.ok(empty.ok)
assert.deepEqual(Object.keys(empty.filter), [])
assert.equal(
entriesImportPath(entriesImportRequest(empty.filter)),
'/export/entries-import.xlsx?format=flat&lang=zh',
)
})
test('批次留空 / 只有空格 = 不筛,不进 query', () => {
for (const raw of ['', ' ']) {
const parsed = parseEntryFilter({ ...emptyState(), eventId: 5, heat: raw })
assert.ok(parsed.ok)
assert.deepEqual(parsed.filter, { eventId: 5 })
}
})
test('批次 0 / 负数 / 小数 / 非数字一律拦下并给同一句提示,不发请求', () => {
for (const raw of ['0', '-1', '1.5', 'abc', '2 批次']) {
const parsed = parseEntryFilter({ ...emptyState(), heat: raw })
assert.equal(parsed.ok, false, `批次「${raw}」应当被拦下`)
assert.ok(!parsed.ok)
assert.equal(parsed.message, ENTRY_HEAT_MESSAGE)
assert.match(parsed.message, /正整数/)
}
// 正整数照旧放行,并且去掉两侧空格。
const parsed = parseEntryFilter({ ...emptyState(), heat: ' 12 ' })
assert.ok(parsed.ok)
assert.deepEqual(parsed.filter, { heat: 12 })
})
test('报表导出那条调用不带 lang:筛选条件里根本没有这两个键', () => {
const parsed = parseEntryFilter({ eventId: 3, categoryId: null, teamId: null, heat: '2' })
assert.ok(parsed.ok)
const reportPath = entriesExportPath(parsed.filter)
assert.equal(reportPath, '/export/entries.xlsx?eventId=3&heat=2')
assert.doesNotMatch(reportPath, /lang|format/)
// 同一份筛选交给导入格式导出,才补上 format / lang。
const importRequest = entriesImportRequest(parsed.filter, { format: 'flat', lang: 'en' })
assert.deepEqual(importRequest, { eventId: 3, heat: 2, format: 'flat', lang: 'en' })
assert.equal(
entriesImportPath(importRequest),
'/export/entries-import.xlsx?format=flat&lang=en&eventId=3&heat=2',
)
})
// ── 文件名与真正发出去的请求 ─────────────────────────────────────────────────
test('两种格式的文件名分得清,且与按钮文案一一对应', () => {
assert.equal(entriesImportXlsxFilename('flat'), '报名名单(导入格式).xlsx')
assert.equal(entriesImportXlsxFilename('sheets'), '报名名单(导入格式·分小项).xlsx')
assert.equal(entriesImportXlsxFilename(), '报名名单(导入格式).xlsx')
assert.equal(ENTRIES_IMPORT_XLSX_FILENAME, '报名名单(导入格式).xlsx')
assert.equal(ENTRIES_IMPORT_SHEETS_XLSX_FILENAME, '报名名单(导入格式·分小项).xlsx')
// 与报表的文件名不能撞在一起,否则现场下载完分不清哪份能导回。
assert.notEqual(ENTRIES_IMPORT_XLSX_FILENAME, ENTRIES_IMPORT_SHEETS_XLSX_FILENAME)
})
test('汇总格式:GET /api/export/entries-import.xlsx?format=flat&lang=zh,文件名带「导入格式」', async () => {
respondWith(() => xlsxResponse())
const filter = parseEntryFilter({ ...emptyState(), eventId: 3 })
assert.ok(filter.ok)
const file: DownloadFile = await downloadEntriesImportXLSX(entriesImportRequest(filter.filter))
assert.equal(lastRequest.url, '/api/export/entries-import.xlsx?format=flat&lang=zh&eventId=3')
assert.equal(lastRequest.method, 'GET')
assert.equal(file.filename, '报名名单(导入格式).xlsx')
assert.equal(file.blob.size, 4)
})
test('分小项格式带 format=sheets,文件名里写清「分小项」', async () => {
respondWith(() => xlsxResponse())
const file = await downloadEntriesImportXLSX({ format: 'sheets', lang: 'en', heat: 2 })
assert.equal(lastRequest.url, '/api/export/entries-import.xlsx?format=sheets&lang=en&heat=2')
assert.equal(file.filename, '报名名单(导入格式·分小项).xlsx')
})
test('服务端 400(参数不对/格式不合法)原样抛出,不产出空文件', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: 'format 只能是 flat 或 sheets' } }, 400),
)
await assert.rejects(
() => downloadEntriesImportXLSX({ format: 'flat' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 400)
assert.equal(error.message, 'format 只能是 flat 或 sheets')
return true
},
)
})
// ── 界面文案:能不能一眼看出「这两个是能导回去的」 ───────────────────────────
test('两个导入格式按钮的文案点明「导入格式 / 可重新导入」,并说清与报表的区别', () => {
assert.deepEqual(
ENTRIES_IMPORT_ACTIONS.map((action) => action.format),
['flat', 'sheets'],
)
const [flat, sheets] = ENTRIES_IMPORT_ACTIONS
assert.equal(flat.label, '导出(汇总·导入格式)')
assert.equal(sheets.label, '导出(分小项·导入格式)')
for (const action of ENTRIES_IMPORT_ACTIONS) {
assert.match(action.title, /导入模板/)
assert.match(action.title, /逐列一致/)
assert.match(action.title, /重新导入/)
assert.match(action.title, /13 列/)
// 报表按钮在旁边:区别必须写出来(报表含小项名称 / 单位名称 / 备注,不能直接导回)。
assert.match(action.title, /报表/)
assert.match(action.title, /备注/)
}
assert.match(flat.title, /一张工作表/)
assert.match(sheets.title, /多张工作表/)
assert.match(sheets.title, /小项名称@组别名称/)
assert.match(ENTRIES_IMPORT_NOTE, /报表/)
assert.match(ENTRIES_IMPORT_NOTE, /不能直接导回/)
assert.match(ENTRIES_IMPORT_NOTE, /13 列/)
})
test('表头语言两个选项默认中文,说明里写清只管两个导入格式、不管报表', () => {
assert.deepEqual(
ENTRIES_IMPORT_LANG_OPTIONS.map((option) => option.lang),
['zh', 'en'],
)
assert.equal(ENTRIES_IMPORT_LANG_OPTIONS[0].label, '中文表头')
assert.equal(ENTRIES_IMPORT_LANG_OPTIONS[1].label, '英文表头')
for (const option of ENTRIES_IMPORT_LANG_OPTIONS) {
assert.match(option.title, /只影响/)
assert.match(option.title, /导出报名名单/)
}
assert.match(ENTRIES_IMPORT_LANG_OPTIONS[1].title, /event_code/)
assert.match(ENTRIES_IMPORT_LANG_NOTE, /只影响/)
assert.match(ENTRIES_IMPORT_LANG_NOTE, /默认中文/)
assert.match(ENTRIES_IMPORT_LANG_NOTE, /没有这个参数/)
})
+72 -7
View File
@@ -34,6 +34,10 @@ const API_ERROR_CODES: readonly string[] = [
export const ENTRIES_XLSX_FILENAME = '报名名单.xlsx'
export const BASEDATA_XLSX_FILENAME = '基础数据包.xlsx'
/** 导入格式(汇总)导出:所有行在一张工作表里。 */
export const ENTRIES_IMPORT_XLSX_FILENAME = '报名名单(导入格式).xlsx'
/** 导入格式(分小项)导出:按「小项名称@组别名称」分成多张工作表。 */
export const ENTRIES_IMPORT_SHEETS_XLSX_FILENAME = '报名名单(导入格式·分小项).xlsx'
export interface DownloadFile {
blob: Blob
@@ -48,6 +52,22 @@ export interface EntryExportFilter {
heat?: number
}
/** 导出组织方式:flat = 所有行汇总在一张工作表;sheets = 按「小项名称@组别名称」分工作表。 */
export type EntriesImportFormat = 'flat' | 'sheets'
/** 表头语言:只有导入格式导出认它(报表导出没有这个参数)。 */
export type EntriesImportLanguage = 'zh' | 'en'
/**
* 导入格式导出的请求参数:四个筛选维度与报表导出完全相同,另加 format / lang。
*
* 缺省 format=flat、lang=zh(与服务端同一口径):调用方拿不准时不必显式写全。
*/
export interface EntriesImportRequest extends EntryExportFilter {
format?: EntriesImportFormat
lang?: EntriesImportLanguage
}
export interface BaseDataFieldDiff {
field: string
from: string
@@ -79,13 +99,33 @@ export interface BaseDataImportResult {
/** 报名名单导出的请求路径(筛选参数为空就不带 query)。 */
export function entriesExportPath(filter: EntryExportFilter = {}): string {
const params = new URLSearchParams()
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
const value = filter[key]
if (value !== undefined) params.append(key, String(value))
}
const query = params.toString()
return `/export/entries.xlsx${query === '' ? '' : `?${query}`}`
const search = appendEntryFilter(new URLSearchParams(), filter).toString()
return `/export/entries.xlsx${search === '' ? '' : `?${search}`}`
}
/**
* 导入格式导出的请求路径:`GET /api/export/entries-import.xlsx?format=…&lang=…&<四个筛选>`。
*
* format / lang 从缺省值补齐后再拼,所以这两个参数总会出现在 query 里;
* 四个筛选维度与报表导出共用 appendEntryFilter,两条路径的筛选口径不会走偏。
* 前端不产生非法 format(界面上只有两个按钮),服务端收到别的值会回 400。
*/
export function entriesImportPath(request: EntriesImportRequest = {}): string {
const params = appendEntryFilter(
new URLSearchParams({ format: request.format ?? 'flat', lang: request.lang ?? 'zh' }),
request,
)
return `/export/entries-import.xlsx?${params.toString()}`
}
/**
* 导入格式导出的文件名。
*
* 分小项那一种在线名里就写清「分小项」:文件名是现场分清「汇总表 / 分小项表」、
* 以及「导入格式 / 给人看的报表」的第一眼线索。
*/
export function entriesImportXlsxFilename(format: EntriesImportFormat = 'flat'): string {
return format === 'sheets' ? ENTRIES_IMPORT_SHEETS_XLSX_FILENAME : ENTRIES_IMPORT_XLSX_FILENAME
}
/** 下载报名名单(xlsx)。 */
@@ -93,6 +133,16 @@ export function downloadEntriesXLSX(filter: EntryExportFilter = {}): Promise<Dow
return download(entriesExportPath(filter), ENTRIES_XLSX_FILENAME)
}
/**
* 下载「导入格式」的报名名单(xlsx):列与报名导入模板逐列一致,可以直接重新导入。
*
* 与上面的 downloadEntriesXLSX 是两种口径:那个是给人看的报表(含小项名称、单位名称、备注),
* 这个只带导入模板认的那 13 列。两个接口在服务端各算各的,前端只是换个地址。
*/
export function downloadEntriesImportXLSX(request: EntriesImportRequest = {}): Promise<DownloadFile> {
return download(entriesImportPath(request), entriesImportXlsxFilename(request.format))
}
/** 下载基础数据包(多 sheet 工作簿)。 */
export function downloadBaseDataXLSX(): Promise<DownloadFile> {
return download('/export/basedata.xlsx', BASEDATA_XLSX_FILENAME)
@@ -142,6 +192,21 @@ export async function importBaseDataXLSX(
// 底层
// ---------------------------------------------------------------------------
/**
* 把四个筛选维度里「有值」的那些拼进 query:未选(undefined)不拼。
*
* 两条导出路径共用这一份,避免出现「报表按 A 口径拼、导入格式按 B 口径拼」这种
* 只在某个维度上悄悄不同的偏差。heat = 0 照旧拼进去(口径是「不传 = 不筛」,
* 页面在拼参数前就把空 / 0 / 非法批次换成 undefined,见 pages/exportEntriesImport.ts)。
*/
function appendEntryFilter(params: URLSearchParams, filter: EntryExportFilter): URLSearchParams {
for (const key of ['eventId', 'categoryId', 'teamId', 'heat'] as const) {
const value = filter[key]
if (value !== undefined) params.append(key, String(value))
}
return params
}
async function download(path: string, filename: string): Promise<DownloadFile> {
const response = await request(path, { method: 'GET' })
return { blob: await response.blob(), filename }