## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
641 lines
24 KiB
Vue
641 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import type { Athlete, EntryWithResult } from '@/shared'
|
||
import MxIcon from '../MxIcon.vue'
|
||
import { adminApi } from '../../api/client'
|
||
import { useBaseData } from './useBaseData'
|
||
import { useToastStore } from '../../stores/toast'
|
||
import {
|
||
buildSelectionRows,
|
||
buildTeamSelectionRows,
|
||
buildTeamSelections,
|
||
emptyTeamPick,
|
||
registeredAthleteIds,
|
||
registeredTeamIds,
|
||
selectableTeamIds,
|
||
selectionAllowsSubmit,
|
||
setPickedTeams,
|
||
teamSelectionAllowsSubmit,
|
||
toggleMemberPick,
|
||
toggleTeamPick,
|
||
toCsv,
|
||
type SelectionMode,
|
||
type TeamPickState,
|
||
} from './entrySelection'
|
||
|
||
/**
|
||
* 从已有运动员名单里勾选报名。
|
||
*
|
||
* 分「个人报名」「团体报名」两部分:小项类型决定默认展示哪一部分,也可以手动切换。
|
||
* 个人部分一人一条报名;团体部分从已配置的参赛单位里多选要参加的单位,
|
||
* 每个单位一条报名,队员名单可以先空着再补(部分单位不参加,默认一个都不勾)。
|
||
*
|
||
* 勾选结果不走新接口:转成与导入表头一致的 CSV 后复用 /entries/import,
|
||
* 服务端的匹配、upsert 与行级校验只有一份实现。
|
||
*/
|
||
|
||
const data = useBaseData()
|
||
const toast = useToastStore()
|
||
|
||
const eventId = ref('')
|
||
const categoryId = ref('')
|
||
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 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) === 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) === individualTeamId.value),
|
||
)
|
||
|
||
const filteredAthletes = computed(() => {
|
||
const keyword = search.value.trim().toLowerCase()
|
||
if (keyword === '') return teamAthletes.value
|
||
return teamAthletes.value.filter(
|
||
(athlete) =>
|
||
athlete.name.toLowerCase().includes(keyword) ||
|
||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword),
|
||
)
|
||
})
|
||
|
||
/** 勾选顺序可能与列表顺序不同,提交前统一按列表顺序取,团体队员名单才稳定。 */
|
||
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.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 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 &&
|
||
selectionAllowsSubmit('individual', checkedAthletes.value.length) &&
|
||
!submitting.value,
|
||
)
|
||
|
||
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 noIdCard && noBib
|
||
}
|
||
|
||
async function loadEntries(id: number): Promise<void> {
|
||
entriesLoading.value = true
|
||
try {
|
||
entries.value = await adminApi.entries(id)
|
||
} catch (caught) {
|
||
entries.value = []
|
||
error.value = caught instanceof Error ? caught.message : '已报名列表加载失败'
|
||
} finally {
|
||
entriesLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 转成导入表头一致的 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
|
||
if (id === '') return
|
||
// loadEventDetail 自带缓存,重复选同一个小项不会二次请求。
|
||
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))
|
||
}
|
||
|
||
watch(eventId, (id) => {
|
||
void syncEvent(id)
|
||
})
|
||
|
||
// 换组别 / 换单位后勾选不再适用,清空避免把上一组的名单带过来。
|
||
watch([categoryId, individualTeamId], () => {
|
||
checked.value = []
|
||
})
|
||
|
||
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('individual', chosen.length)) return
|
||
|
||
submitting.value = true
|
||
error.value = null
|
||
try {
|
||
const rows = buildSelectionRows({
|
||
eventCode: event.code,
|
||
categoryName: category.name,
|
||
teamCode: team.code,
|
||
mode: 'individual',
|
||
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard, bib: athlete.bib })),
|
||
})
|
||
await importSelection(rows, event.id)
|
||
checked.value = []
|
||
} 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
|
||
toast.error('加入报名失败', message)
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">从已有名单勾选报名</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">
|
||
<option value="">选择小项</option>
|
||
<option v-for="event in data.events.value" :key="event.id" :value="String(event.id)">
|
||
{{ event.name }}
|
||
</option>
|
||
</select>
|
||
<select
|
||
v-model="categoryId"
|
||
class="mx-cell-input bd-select"
|
||
:disabled="!data.canWrite.value || eventId === '' || categories.length === 0"
|
||
>
|
||
<option value="" disabled>选择组别</option>
|
||
<option v-for="category in categories" :key="category.id" :value="String(category.id)">
|
||
{{ category.name }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
|
||
<MxIcon name="lock" :size="14" />
|
||
当前不可写入(未登录或服务端未连接),勾选与提交已停用。
|
||
</div>
|
||
<p v-else-if="eventId === ''" class="bd-note">先选择小项和组别,再按小项类型填报。</p>
|
||
<p v-else-if="categories.length === 0" class="bd-note">该小项还没有组别,请先在「组别」分区配置后再报名。</p>
|
||
|
||
<template v-else>
|
||
<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>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.picker-tabs {
|
||
margin-bottom: 14px;
|
||
}
|
||
.picker-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 6px;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
margin-bottom: 12px;
|
||
}
|
||
.picker-row {
|
||
justify-content: flex-start;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: var(--mx-s3);
|
||
}
|
||
.picker-row.is-registered {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.picker-name {
|
||
font-size: 13px;
|
||
color: var(--mx-text);
|
||
}
|
||
.picker-row .bd-note {
|
||
margin-left: auto;
|
||
}
|
||
.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;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
color: var(--mx-amber);
|
||
}
|
||
.picker-error {
|
||
display: block;
|
||
margin-top: 12px;
|
||
padding: 10px 12px;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|