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:
1 parent
19e5630b39
commit
ee19b92182
19 files changed
+2135
-34
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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, /没有这个参数/)
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user