Files
Scoreboard/web/src/admin/components/baseData/EntryPicker.vue
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## 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 例 + 语法检查。
2026-09-11 21:47:07 +08:00

641 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>