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:
AskaEth committed 2026-09-10 11:19:31 +08:00
1 parent b5f40642ff
commit 592e47aef7
19 files changed
+1318 -117

No files matched your search

+424 -92
View File
@@ -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;