feat: 小项类型加「个人+团体」+ 报名导入拆团体/个人两部分
小项类型: - 迁移 0016 给 events.event_type 的 CHECK 增加 individual_team。SQLite 改不了 CHECK, 而 events 被多张子表引用(含 ON DELETE CASCADE/SET NULL),事务内 DROP 重建会级联删数据, 故用 writable_schema 就地改写建表语句文本(只追加一个枚举值,'match' 在建表语句里仅出现一次, 无副作用),零数据搬动 - domain 加 EventTypeIndividualTeam;管理端下拉加「个人+团体」并提示会同时走两条通道; guards/rules 的 eventType 白名单同步(否则会被降级成 individual) - 注意:该类型目前只解决「能被选出来」,双计的算分语义(个人分不计入单位、团体按队内平均排名) 尚未实现,等口径确认后再动数据模型 报名导入: - 卡片拆成「个人报名 / 团体报名」两个 tab,小项+组别提到 tab 之上共享 - 团体报名 = 从已配置单位清单里多选(默认全不勾,正好表达「部分单位不参加」), 可逐单位为登记队员(0 人即纯单位报名),一次提交为多个单位各建一条报名 - 已报名单位仍可覆盖名单,但会提示「提交将清空原名单」,且「全选」只勾启用中且未报名的单位 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 117/117;console 16 文件通过。
This commit is contained in:
1 parent
b5f40642ff
commit
592e47aef7
19 files changed
+1318
-117
No files matched your search
@@ -5,11 +5,31 @@ import MxIcon from '../MxIcon.vue'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import { buildSelectionRows, selectionAllowsSubmit, toCsv, type SelectionMode } from './entrySelection'
|
||||
import {
|
||||
buildSelectionRows,
|
||||
buildTeamSelectionRows,
|
||||
buildTeamSelections,
|
||||
emptyTeamPick,
|
||||
registeredAthleteIds,
|
||||
registeredTeamIds,
|
||||
selectableTeamIds,
|
||||
selectionAllowsSubmit,
|
||||
setPickedTeams,
|
||||
teamSelectionAllowsSubmit,
|
||||
toggleMemberPick,
|
||||
toggleTeamPick,
|
||||
toCsv,
|
||||
type SelectionMode,
|
||||
type TeamPickState,
|
||||
} from './entrySelection'
|
||||
|
||||
/**
|
||||
* 从已有运动员名单里勾选报名。
|
||||
*
|
||||
* 分「个人报名」「团体报名」两部分:小项类型决定默认展示哪一部分,也可以手动切换。
|
||||
* 个人部分一人一条报名;团体部分从已配置的参赛单位里多选要参加的单位,
|
||||
* 每个单位一条报名,队员名单可以先空着再补(部分单位不参加,默认一个都不勾)。
|
||||
*
|
||||
* 勾选结果不走新接口:转成与导入表头一致的 CSV 后复用 /entries/import,
|
||||
* 服务端的匹配、upsert 与行级校验只有一份实现。
|
||||
*/
|
||||
@@ -19,24 +39,40 @@ const toast = useToastStore()
|
||||
|
||||
const eventId = ref('')
|
||||
const categoryId = ref('')
|
||||
const teamId = ref('')
|
||||
const search = ref('')
|
||||
const checked = ref<number[]>([])
|
||||
const entries = ref<EntryWithResult[]>([])
|
||||
const entriesLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// 个人报名
|
||||
const individualTeamId = ref('')
|
||||
const search = ref('')
|
||||
const checked = ref<number[]>([])
|
||||
|
||||
// 团体报名
|
||||
const teamPick = ref<TeamPickState>(emptyTeamPick())
|
||||
const expandedTeams = ref<number[]>([])
|
||||
const memberSearch = ref('')
|
||||
|
||||
const eventIdNum = computed(() => (eventId.value === '' ? null : Number(eventId.value)))
|
||||
const currentEvent = computed(() => data.eventById(eventIdNum.value))
|
||||
const mode = computed<SelectionMode>(() => (currentEvent.value?.eventType === 'individual' ? 'individual' : 'team'))
|
||||
const categories = computed(() => data.categoriesOf(eventIdNum.value))
|
||||
const currentCategory = computed(() => categories.value.find((category) => String(category.id) === categoryId.value))
|
||||
const currentTeam = computed(() => data.teams.value.find((team) => String(team.id) === teamId.value))
|
||||
const currentTeam = computed(() => data.teams.value.find((team) => String(team.id) === individualTeamId.value))
|
||||
|
||||
/** 小项类型对应的部分:个人项目 → 个人报名,团体 / 对抗项目 → 团体报名。 */
|
||||
const eventMode = computed<SelectionMode>(() => (currentEvent.value?.eventType === 'individual' ? 'individual' : 'team'))
|
||||
const tab = ref<SelectionMode>('individual')
|
||||
/** 手动切到与小项类型不符的部分时提示一句,避免报错时看不出原因。 */
|
||||
const tabMismatch = computed(() => currentEvent.value !== undefined && tab.value !== eventMode.value)
|
||||
|
||||
function setTab(next: SelectionMode): void {
|
||||
tab.value = next
|
||||
}
|
||||
|
||||
/** 只列启用中的运动员:停用为软删除,新报名不再可选。 */
|
||||
const teamAthletes = computed(() =>
|
||||
data.athletes.value.filter((athlete) => athlete.active && String(athlete.teamId) === teamId.value),
|
||||
data.athletes.value.filter((athlete) => athlete.active && String(athlete.teamId) === individualTeamId.value),
|
||||
)
|
||||
|
||||
const filteredAthletes = computed(() => {
|
||||
@@ -52,43 +88,117 @@ const filteredAthletes = computed(() => {
|
||||
/** 勾选顺序可能与列表顺序不同,提交前统一按列表顺序取,团体队员名单才稳定。 */
|
||||
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id)))
|
||||
|
||||
/** 个人项目按 athleteId 判断已报名;团体项目按单位整体判断。 */
|
||||
const registeredAthleteIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const entry of entries.value) {
|
||||
if (entry.categoryId !== currentCategory.value?.id) continue
|
||||
if (entry.teamId !== currentTeam.value?.id) continue
|
||||
if (entry.athleteId !== null) ids.add(entry.athleteId)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const teamRegistered = computed(() =>
|
||||
entries.value.some(
|
||||
(entry) => entry.categoryId === currentCategory.value?.id && entry.teamId === currentTeam.value?.id,
|
||||
),
|
||||
const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id))
|
||||
const registeredAthletes = computed(() =>
|
||||
registeredAthleteIds(entries.value, currentCategory.value?.id, currentTeam.value?.id),
|
||||
)
|
||||
const individualTeamRegistered = computed(
|
||||
() => currentTeam.value !== undefined && registeredTeams.value.has(currentTeam.value.id),
|
||||
)
|
||||
|
||||
const canSubmit = computed(
|
||||
/** 团体部分要提交的单位:单位与队员都按列表顺序产出。 */
|
||||
const teamSelections = computed(() => buildTeamSelections(teamPick.value, data.teams.value, data.athletes.value))
|
||||
const pickedTeamCount = computed(() => teamSelections.value.length)
|
||||
/** 「全选」的范围:启用中且还没报名的单位。 */
|
||||
const selectableTeams = computed(() => selectableTeamIds(data.teams.value, registeredTeams.value))
|
||||
|
||||
/** 展开的单位里可勾选的队员;搜索只作用于展开的名单。 */
|
||||
const membersByTeam = computed(() => {
|
||||
const keyword = memberSearch.value.trim().toLowerCase()
|
||||
const map = new Map<number, Athlete[]>()
|
||||
for (const athlete of data.athletes.value) {
|
||||
if (!athlete.active) continue
|
||||
if (
|
||||
keyword !== '' &&
|
||||
!athlete.name.toLowerCase().includes(keyword) &&
|
||||
!(athlete.employeeNo ?? '').toLowerCase().includes(keyword)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const list = map.get(athlete.teamId)
|
||||
if (list === undefined) map.set(athlete.teamId, [athlete])
|
||||
else list.push(athlete)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function teamMembersOf(teamId: number): Athlete[] {
|
||||
return membersByTeam.value.get(teamId) ?? []
|
||||
}
|
||||
|
||||
const canSubmitIndividual = computed(
|
||||
() =>
|
||||
data.canWrite.value &&
|
||||
currentEvent.value !== undefined &&
|
||||
currentCategory.value !== undefined &&
|
||||
currentTeam.value !== undefined &&
|
||||
// 团体 / 对抗项目允许只报单位(0 勾选也放行),个人项目必须勾人。
|
||||
selectionAllowsSubmit(mode.value, checkedAthletes.value.length) &&
|
||||
selectionAllowsSubmit('individual', checkedAthletes.value.length) &&
|
||||
!submitting.value,
|
||||
)
|
||||
|
||||
function isRegistered(athlete: Athlete): boolean {
|
||||
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
|
||||
const canSubmitTeam = computed(
|
||||
() =>
|
||||
data.canWrite.value &&
|
||||
currentEvent.value !== undefined &&
|
||||
currentCategory.value !== undefined &&
|
||||
teamSelectionAllowsSubmit(pickedTeamCount.value) &&
|
||||
!submitting.value,
|
||||
)
|
||||
|
||||
function isTeamPicked(teamId: number): boolean {
|
||||
return teamPick.value.teamIds.includes(teamId)
|
||||
}
|
||||
|
||||
/** 个人项目的行必须带证件号或号码布;团体行运动员为空,不受影响。 */
|
||||
function isMemberPicked(teamId: number, athleteId: number): boolean {
|
||||
return (teamPick.value.memberIds[teamId] ?? []).includes(athleteId)
|
||||
}
|
||||
|
||||
function memberCount(teamId: number): number {
|
||||
return teamPick.value.memberIds[teamId]?.length ?? 0
|
||||
}
|
||||
|
||||
function isExpanded(teamId: number): boolean {
|
||||
return expandedTeams.value.includes(teamId)
|
||||
}
|
||||
|
||||
/** 已报名的单位勾上却没登记队员时,提交会把原名单覆盖成空,提前在行上提醒一句。 */
|
||||
function clearsRoster(teamId: number): boolean {
|
||||
return isTeamPicked(teamId) && registeredTeams.value.has(teamId) && memberCount(teamId) === 0
|
||||
}
|
||||
|
||||
function toggleExpand(teamId: number): void {
|
||||
expandedTeams.value = isExpanded(teamId)
|
||||
? expandedTeams.value.filter((id) => id !== teamId)
|
||||
: [...expandedTeams.value, teamId]
|
||||
}
|
||||
|
||||
/** 手动勾选单位时顺手展开队员列表;「全选」不展开,否则一次铺开几十个面板。 */
|
||||
function onTeamCheck(teamId: number, event: Event): void {
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
teamPick.value = toggleTeamPick(teamPick.value, teamId, input.checked)
|
||||
if (input.checked && !isExpanded(teamId)) expandedTeams.value = [...expandedTeams.value, teamId]
|
||||
}
|
||||
|
||||
function onMemberCheck(teamId: number, athleteId: number, event: Event): void {
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
teamPick.value = toggleMemberPick(teamPick.value, teamId, athleteId, input.checked)
|
||||
}
|
||||
|
||||
function selectAllTeams(): void {
|
||||
teamPick.value = setPickedTeams(teamPick.value, selectableTeams.value)
|
||||
}
|
||||
|
||||
function clearTeams(): void {
|
||||
teamPick.value = setPickedTeams(teamPick.value, [])
|
||||
}
|
||||
|
||||
/** 个人报名的行必须带证件号或号码布;两者都没有时服务端会跳过该行。 */
|
||||
function missingIdentifier(athlete: Athlete): boolean {
|
||||
const noIdCard = athlete.idCard === null || athlete.idCard === ''
|
||||
const noBib = athlete.bib === null || athlete.bib === ''
|
||||
return mode.value === 'individual' && noIdCard && noBib
|
||||
return noIdCard && noBib
|
||||
}
|
||||
|
||||
async function loadEntries(id: number): Promise<void> {
|
||||
@@ -103,9 +213,30 @@ async function loadEntries(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncEvent(id: string): Promise<void> {
|
||||
/** 转成导入表头一致的 CSV 后复用导入接口;提交后重拉已报名,徽标与计数都是服务端的结果。 */
|
||||
async function importSelection(rows: string[][], id: number): Promise<void> {
|
||||
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
|
||||
const result = await data.importEntries(file)
|
||||
toast.success('报名已加入', `新增 ${result.created} · 更新 ${result.updated} · 跳过 ${result.skipped}`)
|
||||
if (result.errors.length > 0) error.value = result.errors.join(';')
|
||||
await loadEntries(id)
|
||||
}
|
||||
|
||||
function resetTeamPick(): void {
|
||||
teamPick.value = emptyTeamPick()
|
||||
expandedTeams.value = []
|
||||
memberSearch.value = ''
|
||||
}
|
||||
|
||||
function resetSelection(): void {
|
||||
checked.value = []
|
||||
search.value = ''
|
||||
individualTeamId.value = ''
|
||||
resetTeamPick()
|
||||
}
|
||||
|
||||
async function syncEvent(id: string): Promise<void> {
|
||||
resetSelection()
|
||||
categoryId.value = ''
|
||||
entries.value = []
|
||||
error.value = null
|
||||
@@ -114,6 +245,8 @@ async function syncEvent(id: string): Promise<void> {
|
||||
await data.loadEventDetail(Number(id))
|
||||
const first = data.categoriesOf(Number(id))[0]
|
||||
categoryId.value = first === undefined ? '' : String(first.id)
|
||||
// 换小项就回到该类型对应的部分,省得在上一部分里继续填。
|
||||
tab.value = eventMode.value
|
||||
await loadEntries(Number(id))
|
||||
}
|
||||
|
||||
@@ -122,17 +255,22 @@ watch(eventId, (id) => {
|
||||
})
|
||||
|
||||
// 换组别 / 换单位后勾选不再适用,清空避免把上一组的名单带过来。
|
||||
watch([categoryId, teamId], () => {
|
||||
watch([categoryId, individualTeamId], () => {
|
||||
checked.value = []
|
||||
})
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
watch(categoryId, () => {
|
||||
teamPick.value = emptyTeamPick()
|
||||
expandedTeams.value = []
|
||||
})
|
||||
|
||||
async function submitIndividual(): Promise<void> {
|
||||
const event = currentEvent.value
|
||||
const category = currentCategory.value
|
||||
const team = currentTeam.value
|
||||
const chosen = checkedAthletes.value
|
||||
if (event === undefined || category === undefined || team === undefined) return
|
||||
if (!selectionAllowsSubmit(mode.value, chosen.length)) return
|
||||
if (!selectionAllowsSubmit('individual', chosen.length)) return
|
||||
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
@@ -141,15 +279,38 @@ async function submit(): Promise<void> {
|
||||
eventCode: event.code,
|
||||
categoryName: category.name,
|
||||
teamCode: team.code,
|
||||
mode: mode.value,
|
||||
mode: 'individual',
|
||||
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard, bib: athlete.bib })),
|
||||
})
|
||||
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
|
||||
const result = await data.importEntries(file)
|
||||
toast.success('报名已加入', `新增 ${result.created} · 更新 ${result.updated} · 跳过 ${result.skipped}`)
|
||||
if (result.errors.length > 0) error.value = result.errors.join(';')
|
||||
await importSelection(rows, event.id)
|
||||
checked.value = []
|
||||
await loadEntries(event.id)
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : '加入报名失败'
|
||||
error.value = message
|
||||
toast.error('加入报名失败', message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTeam(): Promise<void> {
|
||||
const event = currentEvent.value
|
||||
const category = currentCategory.value
|
||||
const teams = teamSelections.value
|
||||
if (event === undefined || category === undefined) return
|
||||
if (!teamSelectionAllowsSubmit(teams.length)) return
|
||||
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: event.code,
|
||||
categoryName: category.name,
|
||||
teams,
|
||||
})
|
||||
await importSelection(rows, event.id)
|
||||
// 提交成功后清空勾选:已报名的单位会转为「已报名」而不可再勾。
|
||||
resetTeamPick()
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : '加入报名失败'
|
||||
error.value = message
|
||||
@@ -164,11 +325,28 @@ async function submit(): Promise<void> {
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">从已有名单勾选报名</span>
|
||||
<span v-if="mode === 'team' && teamRegistered" class="mx-badge mx-badge-warning">该单位已报名</span>
|
||||
<span v-if="tab === 'individual' && individualTeamRegistered" class="mx-badge mx-badge-warning">该单位已报名</span>
|
||||
<span v-else-if="tab === 'team' && pickedTeamCount > 0" class="mx-badge mx-badge-info">
|
||||
已勾选 {{ pickedTeamCount }} 个单位
|
||||
</span>
|
||||
<span v-else-if="entriesLoading" class="mx-badge mx-badge-neutral">读取已报名…</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div class="mx-tabs mx-tabs-pill picker-tabs">
|
||||
<button
|
||||
class="mx-tab"
|
||||
:class="{ active: tab === 'individual' }"
|
||||
type="button"
|
||||
@click="setTab('individual')"
|
||||
>
|
||||
个人报名
|
||||
</button>
|
||||
<button class="mx-tab" :class="{ active: tab === 'team' }" type="button" @click="setTab('team')">
|
||||
团体报名
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<select v-model="eventId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value">
|
||||
@@ -187,17 +365,6 @@ async function submit(): Promise<void> {
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="teamId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value || eventId === ''">
|
||||
<option value="" disabled>选择单位</option>
|
||||
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
|
||||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,56 +372,178 @@ async function submit(): Promise<void> {
|
||||
<MxIcon name="lock" :size="14" />
|
||||
当前不可写入(未登录或服务端未连接),勾选与提交已停用。
|
||||
</div>
|
||||
<p v-else-if="eventId === ''" class="bd-note">先选择小项、组别和单位,再勾选运动员。</p>
|
||||
<p v-else-if="eventId === ''" class="bd-note">先选择小项和组别,再按小项类型填报。</p>
|
||||
<p v-else-if="categories.length === 0" class="bd-note">该小项还没有组别,请先在「组别」分区配置后再报名。</p>
|
||||
<p v-else-if="teamId === ''" class="bd-note">再选择一个单位,即可看到该单位的运动员。</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<div class="mx-input-wrap bd-search">
|
||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
|
||||
<p v-if="tabMismatch" class="bd-note">
|
||||
该小项配置为{{ eventMode === 'individual' ? '个人项目' : '团体 / 对抗项目' }},当前用的是{{
|
||||
tab === 'individual' ? '个人报名' : '团体报名'
|
||||
}},生成的报名行以当前部分为准。
|
||||
</p>
|
||||
|
||||
<!-- 个人报名:一人一条报名 -->
|
||||
<template v-if="tab === 'individual'">
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<select v-model="individualTeamId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value">
|
||||
<option value="" disabled>选择单位</option>
|
||||
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
|
||||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="mx-input-wrap bd-search">
|
||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bd-toolbar-right">
|
||||
<span class="bd-note">已勾选 {{ checkedAthletes.length }} 人</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canSubmitIndividual"
|
||||
@click="submitIndividual"
|
||||
>
|
||||
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="bd-note">
|
||||
已勾选 {{ checkedAthletes.length }} 人{{ mode === 'team' && checkedAthletes.length === 0 ? '(团体项目可留空)' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="mode === 'team'" class="bd-note">
|
||||
团体 / 对抗项目按单位报名:队员名单可选,一名队员都不勾也能把本单位的报名建出来,名单可以之后再来补。
|
||||
</p>
|
||||
<p v-if="individualTeamId === ''" class="bd-note">选择一个单位,即可看到该单位的运动员。</p>
|
||||
<template v-else>
|
||||
<div class="picker-list">
|
||||
<label
|
||||
v-for="athlete in filteredAthletes"
|
||||
:key="athlete.id"
|
||||
class="bd-check picker-row"
|
||||
:class="{ 'is-registered': registeredAthletes.has(athlete.id) }"
|
||||
>
|
||||
<input
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
:value="athlete.id"
|
||||
:disabled="!data.canWrite.value || registeredAthletes.has(athlete.id) || missingIdentifier(athlete)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">
|
||||
{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}
|
||||
</span>
|
||||
<span v-if="registeredAthletes.has(athlete.id)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
|
||||
</label>
|
||||
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
||||
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-list">
|
||||
<label
|
||||
v-for="athlete in filteredAthletes"
|
||||
:key="athlete.id"
|
||||
class="bd-check picker-row"
|
||||
:class="{ 'is-registered': isRegistered(athlete) }"
|
||||
>
|
||||
<input
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
:value="athlete.id"
|
||||
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdentifier(athlete)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}</span>
|
||||
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
|
||||
</label>
|
||||
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
|
||||
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 团体报名:多选参赛单位,每个单位一条报名 -->
|
||||
<template v-else>
|
||||
<div class="bd-toolbar">
|
||||
<div class="bd-toolbar-left">
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value"
|
||||
@click="selectAllTeams"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || pickedTeamCount === 0"
|
||||
@click="clearTeams"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<span class="bd-note">已勾选 {{ pickedTeamCount }} 个参赛单位</span>
|
||||
<div v-if="expandedTeams.length > 0" class="mx-input-wrap bd-search">
|
||||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||
<input v-model="memberSearch" class="mx-input" placeholder="搜索队员姓名 / 工号" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bd-toolbar-right">
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSubmitTeam" @click="submitTeam">
|
||||
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="bd-note">
|
||||
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
|
||||
<template v-else>团体 / 对抗项目:勾选的人合成一条报名(一个人也不勾则只报单位),队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
|
||||
</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
<p class="bd-note">
|
||||
团体 / 对抗项目按单位报名:从已配置的参赛单位里勾选参加本组别的单位(默认一个都不勾,
|
||||
部分单位不参加就不勾它)。每个单位生成一条报名,队员名单可选——展开某个单位即可登记队员,
|
||||
一个队员都不勾则只报单位。「全选」只勾启用中且未报名的单位,已停用单位要单独勾。
|
||||
</p>
|
||||
|
||||
<div class="team-list">
|
||||
<div
|
||||
v-for="team in data.teams.value"
|
||||
:key="team.id"
|
||||
class="team-pick"
|
||||
:class="{ 'is-picked': isTeamPicked(team.id) }"
|
||||
>
|
||||
<div class="team-row">
|
||||
<label class="bd-check team-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isTeamPicked(team.id)"
|
||||
:disabled="!data.canWrite.value"
|
||||
@change="onTeamCheck(team.id, $event)"
|
||||
/>
|
||||
<span class="picker-name">{{ team.name }}{{ team.active ? '' : '(已停用)' }}</span>
|
||||
<span class="bd-note bd-mono">{{ team.code }}</span>
|
||||
</label>
|
||||
<div class="team-actions">
|
||||
<span v-if="registeredTeams.has(team.id)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-if="memberCount(team.id) > 0" class="mx-badge mx-badge-info">
|
||||
队员 {{ memberCount(team.id) }} 人
|
||||
</span>
|
||||
<span v-if="clearsRoster(team.id)" class="mx-badge mx-badge-warning">提交将清空原名单</span>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="toggleExpand(team.id)">
|
||||
{{ isExpanded(team.id) ? '收起队员' : '登记队员' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(team.id)" class="team-members">
|
||||
<div class="picker-list member-list">
|
||||
<label v-for="athlete in teamMembersOf(team.id)" :key="athlete.id" class="bd-check picker-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isMemberPicked(team.id, athlete.id)"
|
||||
:disabled="!data.canWrite.value"
|
||||
@change="onMemberCheck(team.id, athlete.id, $event)"
|
||||
/>
|
||||
<span class="picker-name">{{ athlete.name }}</span>
|
||||
<span class="bd-note">
|
||||
{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="teamMembersOf(team.id).length === 0" class="bd-note picker-empty">
|
||||
{{ memberSearch.trim() === '' ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.teams.value.length === 0" class="bd-note picker-empty">
|
||||
还没有配置参赛单位,请先在「参赛单位」分区添加。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="bd-note">
|
||||
每个单位生成一条报名(同组别 + 单位只留一条),队员名单按列表顺序写入。
|
||||
已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空,
|
||||
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位。
|
||||
</p>
|
||||
<p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p>
|
||||
</template>
|
||||
|
||||
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
|
||||
</template>
|
||||
@@ -263,6 +552,9 @@ async function submit(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker-tabs {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.picker-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
@@ -292,6 +584,46 @@ async function submit(): Promise<void> {
|
||||
.picker-empty {
|
||||
padding: 12px 0;
|
||||
}
|
||||
.team-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.team-pick {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.team-pick.is-picked {
|
||||
border-color: var(--mx-primary);
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
.team-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.team-check {
|
||||
min-width: 0;
|
||||
}
|
||||
.team-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.team-members {
|
||||
padding: 8px 10px 2px;
|
||||
border-top: 1px dashed var(--mx-divider);
|
||||
}
|
||||
.member-list {
|
||||
max-height: 240px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.readonly-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -30,6 +30,7 @@ const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
|
||||
{ value: 'individual', label: '个人' },
|
||||
{ value: 'team', label: '团体' },
|
||||
{ value: 'match', label: '对抗 / 比赛' },
|
||||
{ value: 'individual_team', label: '个人+团体' },
|
||||
]
|
||||
const RESULT_MODE_OPTIONS: Array<{ value: ResultMode; label: string }> = [
|
||||
{ value: 'auto', label: '自动排名(录成绩)' },
|
||||
@@ -259,6 +260,13 @@ const eventConfirmOpen = ref(false)
|
||||
const eventConfirmTarget = ref<Event | null>(null)
|
||||
const eventConfirmPending = ref(false)
|
||||
|
||||
// 「个人+团体」要说明双通道口径;其余类型维持原提示。
|
||||
const eventTypeHint = computed(() =>
|
||||
eventForm.value.eventType === 'individual_team'
|
||||
? '会同时按个人通道与团体通道记分;具体通道可在「计分通道」页调整'
|
||||
: '仅用于展示归类',
|
||||
)
|
||||
|
||||
function emptyEventForm(): EventForm {
|
||||
return {
|
||||
sportId: '',
|
||||
@@ -625,7 +633,7 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<FormField label="名称" required :error="eventErrors.name" wide>
|
||||
<input v-model="eventForm.name" class="mx-cell-input" placeholder="例如 男子 100 米" />
|
||||
</FormField>
|
||||
<FormField label="项目类型" hint="仅用于展示归类">
|
||||
<FormField label="项目类型" :hint="eventTypeHint">
|
||||
<select v-model="eventForm.eventType" class="mx-cell-input">
|
||||
<option v-for="option in EVENT_TYPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
|
||||
@@ -99,22 +99,169 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
return rows
|
||||
}
|
||||
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
|
||||
rows.push([
|
||||
input.eventCode,
|
||||
input.categoryName,
|
||||
input.teamCode,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
return buildTeamSelectionRows({
|
||||
eventCode: input.eventCode,
|
||||
categoryName: input.categoryName,
|
||||
status,
|
||||
names.join('/'),
|
||||
])
|
||||
teams: [{ teamCode: input.teamCode, memberNames: names }],
|
||||
})
|
||||
}
|
||||
|
||||
/** 一个单位的团体报名:只带单位编码与可选队员名单。 */
|
||||
export interface TeamSelection {
|
||||
teamCode: string
|
||||
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
|
||||
memberNames: string[]
|
||||
}
|
||||
|
||||
export interface TeamSelectionInput {
|
||||
eventCode: string
|
||||
categoryName: string
|
||||
status?: string
|
||||
/** 一次提交可以覆盖多个单位,勾了哪些单位就产出哪些行。 */
|
||||
teams: TeamSelection[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 多单位团体报名:每个单位一行,队员姓名进 member_names。
|
||||
*
|
||||
* 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert,
|
||||
* 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。
|
||||
*/
|
||||
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
const status = input.status ?? 'registered'
|
||||
const rows: string[][] = [[...REQUIRED_COLUMNS]]
|
||||
for (const team of input.teams) {
|
||||
rows.push([
|
||||
input.eventCode,
|
||||
input.categoryName,
|
||||
team.teamCode,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
status,
|
||||
team.memberNames.join('/'),
|
||||
])
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// ── 团体多单位勾选:状态与产出 ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 团体报名的勾选状态:哪些单位参加 + 每个单位要登记的队员。
|
||||
*
|
||||
* 单位与队员分开存:取消勾选单位不清队员名单,重新勾上即恢复,避免手滑丢名单;
|
||||
* 产出(buildTeamSelections)只取已勾选单位的队员。
|
||||
*/
|
||||
export interface TeamPickState {
|
||||
teamIds: number[]
|
||||
memberIds: Record<number, number[]>
|
||||
}
|
||||
|
||||
export function emptyTeamPick(): TeamPickState {
|
||||
return { teamIds: [], memberIds: {} }
|
||||
}
|
||||
|
||||
/** 勾选 / 取消一个要参加的单位。 */
|
||||
export function toggleTeamPick(state: TeamPickState, teamId: number, picked: boolean): TeamPickState {
|
||||
const teamIds = picked
|
||||
? state.teamIds.includes(teamId)
|
||||
? state.teamIds
|
||||
: [...state.teamIds, teamId]
|
||||
: state.teamIds.filter((id) => id !== teamId)
|
||||
return { ...state, teamIds }
|
||||
}
|
||||
|
||||
/** 勾选 / 取消某单位的一名队员;单位没勾选也可以先登记名单。 */
|
||||
export function toggleMemberPick(
|
||||
state: TeamPickState,
|
||||
teamId: number,
|
||||
athleteId: number,
|
||||
picked: boolean,
|
||||
): TeamPickState {
|
||||
const current = state.memberIds[teamId] ?? []
|
||||
const next = picked
|
||||
? current.includes(athleteId)
|
||||
? current
|
||||
: [...current, athleteId]
|
||||
: current.filter((id) => id !== athleteId)
|
||||
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
|
||||
}
|
||||
|
||||
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */
|
||||
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
|
||||
return { ...state, teamIds: [...teamIds] }
|
||||
}
|
||||
|
||||
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
|
||||
export function selectableTeamIds(
|
||||
teams: readonly { id: number; active: boolean }[],
|
||||
registered: ReadonlySet<number>,
|
||||
): number[] {
|
||||
return teams.filter((team) => team.active && !registered.has(team.id)).map((team) => team.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 勾选状态 → 提交用的单位列表。
|
||||
*
|
||||
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
|
||||
* 保证同一份勾选每次生成的 member_names 完全一致。
|
||||
*/
|
||||
export function buildTeamSelections(
|
||||
state: TeamPickState,
|
||||
teams: readonly { id: number; code: string }[],
|
||||
athletes: readonly { id: number; teamId: number; name: string; active: boolean }[],
|
||||
): TeamSelection[] {
|
||||
const picked = new Set(state.teamIds)
|
||||
const selections: TeamSelection[] = []
|
||||
for (const team of teams) {
|
||||
if (!picked.has(team.id)) continue
|
||||
const memberIds = new Set(state.memberIds[team.id] ?? [])
|
||||
const memberNames = athletes
|
||||
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
|
||||
.map((athlete) => athlete.name)
|
||||
selections.push({ teamCode: team.code, memberNames })
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
/** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
|
||||
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
|
||||
return pickedTeamCount > 0
|
||||
}
|
||||
|
||||
/** 组别下已报名的单位 id;团体行按 组别 + 单位 判断,与列表里的「已报名」徽标同源。 */
|
||||
export function registeredTeamIds(
|
||||
entries: readonly { categoryId: number; teamId: number }[],
|
||||
categoryId: number | undefined,
|
||||
): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
if (categoryId === undefined) return ids
|
||||
for (const entry of entries) {
|
||||
if (entry.categoryId === categoryId) ids.add(entry.teamId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
|
||||
export function registeredAthleteIds(
|
||||
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
|
||||
categoryId: number | undefined,
|
||||
teamId: number | undefined,
|
||||
): Set<number> {
|
||||
const ids = new Set<number>()
|
||||
if (categoryId === undefined || teamId === undefined) return ids
|
||||
for (const entry of entries) {
|
||||
if (entry.categoryId !== categoryId || entry.teamId !== teamId) continue
|
||||
if (entry.athleteId !== null) ids.add(entry.athleteId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** 转成 CSV 文本;服务端用标准 csv 解析器读取,转义必须符合 RFC 4180。 */
|
||||
export function toCsv(rows: string[][]): string {
|
||||
return rows.map((row) => row.map(escapeField).join(',')).join('\n')
|
||||
|
||||
Reference in new issue
Block a user