## 现场现象
在「按单位手动录名次」的项目(例如篮球)的录成绩页,往底部「团体名次」输入框里填一个数字,
**整个录入页立刻变空白**,控制台 `TypeError: e.trim is not a function`(栈里是 computed 的 getter)。
## 根因(老 bug,不是联队引入)
- 模板是 `v-model="row.input"` + `type="number"`。
- **Vue 3 的 `vModelText` 对 `type="number"` 会自动做数值转换**
(`castToNumber = number || (vnode.props && vnode.props.type === 'number')`)→ 填了数字之后值是
`number`;只有**清空**时 `looseToNumber('')` 才留空串。当初的注释只写了后一半,漏了前一半。
- `teamRanks.ts` 的 `parseTeamRankInput(raw: string)` 里直接 `raw.trim()` → TypeError;
而它的三个调用点(保存载荷 / 重复名次提示 / 描红)**全在渲染期的 computed 里** → 整页白屏。
证据:0.5.2(联队之前)的 `EntryPage.vue` 里就已是这套写法,`teamRanks.ts` 本轮一个字没改过。
## 修复
- `teamRanks.ts`:`parseTeamRankInput` 参数与 `TeamRankRow.input` 类型如实标注 `string | number`,
解析前 `String(raw).trim()`;`teamRanksDirty` 归一后比较(否则 `number 3` 与 `string '3'` 会被判成
「已修改」,保存按钮空亮着却什么都没改)。
- `EntryPage.vue`:**同类第二处** —— 「批量设置批次」的 `heatInput` / `heatSizeInput` 同样是
`type="number"` 且下游 `.trim()`,**同样会整页白屏**,一并修掉。
- 写法与仓库既有的正确实现保持一致(`CategoriesSection.vue` / `AthletesSection.vue` 的 `toAge()`)。
## 同类隐患体检
把全仓库 **21 个 `type="number"` 的 v-model 绑定点**逐个追了下游用法(解析 / 比较 / `.trim()` / 传接口):
**真正会炸的只有上面这 3 处**,其余 18 处本来就用 `Number()` 归一,安全。
## 验证
- **先红后绿**:修复前 3 例红(`raw.trim is not a function` 原地复现:number 解析抛错、computed 调用链抛错、
脏判断误报 `true !== false`),修复后 13/13 通过。
- 前端用例 809 → **813**,0 fail;`vue-tsc` / `pnpm build` 退出码 0。
- 全量自检两轮全绿(服务端 14 包 / Console 68 例);退出码直接读,未用管道吞。
## 版本
`dev_0.5.3` → **`dev_0.5.4`**(0.5.3 已产出并记过哈希,不重出同名包);安装器素材同步重生成
(版本号烙在位图里,实测仍只有 `brand.bmp` 的 1x/2x 两张变化)。
3591 lines
138 KiB
Vue
3591 lines
138 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
eventInputOf,
|
||
formatPoints,
|
||
hasAggregateChannel,
|
||
isJointTeam,
|
||
jointTeamLabel,
|
||
normalizeTeamShares,
|
||
parseResultValue,
|
||
shareText,
|
||
type AvgMode,
|
||
type DraftResultInput,
|
||
type EntryStatus,
|
||
type EntryWithResult,
|
||
type Event,
|
||
type ResultState,
|
||
type ResultStatus,
|
||
type TeamRanksView,
|
||
type TeamShareView,
|
||
} from '@/shared'
|
||
import MxIcon from '../components/MxIcon.vue'
|
||
import PageHeader from '../components/PageHeader.vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
|
||
import {
|
||
DISCARD_CONFIRM_MESSAGE,
|
||
DISCARD_CONFIRM_TITLE,
|
||
createLeaveGuard,
|
||
judgeEntryRow,
|
||
resultCellHint,
|
||
type EntryRowJudgement,
|
||
type ResultCellHint,
|
||
} from './entryGuards'
|
||
import { entryProgress } from './entryProgress'
|
||
import {
|
||
hasTeamManualChannel,
|
||
teamRankFlaggedTeams,
|
||
teamRankRowsOf,
|
||
teamRankWarnings,
|
||
teamRanksDirty,
|
||
teamRanksPayload,
|
||
type TeamRankRow,
|
||
} from './teamRanks'
|
||
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
|
||
import {
|
||
addShareDraftTeam,
|
||
removeShareDraftTeam,
|
||
selectableShareTeams,
|
||
setShareDraftWeight,
|
||
shareDraftDirty,
|
||
shareDraftIssues,
|
||
shareDraftOf,
|
||
shareDraftPayload,
|
||
shareDraftView,
|
||
submitEntryTeamShares,
|
||
type ShareDraftRow,
|
||
} from './entryShares'
|
||
import {
|
||
entryBatchOutcome,
|
||
entryBatchSizeError,
|
||
planEntryBatch,
|
||
submitEntryBatch,
|
||
type EntryBatchAction,
|
||
type EntryBatchPlan,
|
||
type EntryBatchResult,
|
||
type EntryBatchRow,
|
||
} from './entryBatch'
|
||
import {
|
||
RESULT_ACTION_CONFIRM,
|
||
RESULT_ACTION_SUCCESS,
|
||
RESULT_ROW_ACTION_LABEL,
|
||
RESULT_ROW_ACTION_TITLE,
|
||
RESULT_ROW_ACTION_WORD,
|
||
resultActionTarget,
|
||
resultActionTargets,
|
||
resultCellAction,
|
||
type ResultActionTarget,
|
||
type ResultRowAction,
|
||
} from './entryResultActions'
|
||
import {
|
||
DEFAULT_HEAT_SIZE,
|
||
MAX_HEAT,
|
||
applyHeatToRows,
|
||
filterRowsByHeat,
|
||
heatLabel,
|
||
heatOptions,
|
||
maxHeat,
|
||
planHeatSplit,
|
||
type HeatAllocation,
|
||
type HeatFilter,
|
||
} from './entryHeat'
|
||
import { adminApi, sampleDataBlockedNotice } from '../api/client'
|
||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||
import EntryPicker from '../components/baseData/EntryPicker.vue'
|
||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||
import { useBaseData } from '../components/baseData/useBaseData'
|
||
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useEventStore } from '../stores/event'
|
||
import { useToastStore } from '../stores/toast'
|
||
|
||
/** 表格里的一行:报名项 + 正在编辑的成绩。 */
|
||
interface EditableRow {
|
||
entryId: number
|
||
categoryId: number
|
||
bib: string | null
|
||
teamId: number
|
||
athleteId: number | null
|
||
teamName: string
|
||
teamShort: string
|
||
/**
|
||
* 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。
|
||
*
|
||
* 表格的「单位」列与实时积分预览都读它——联队那条的分要拆给各成员单位,
|
||
* 只看 teamId 会把整份分记在主单位头上。
|
||
*/
|
||
teamShares: TeamShareView[]
|
||
athleteName: string
|
||
memberNames: string[] | null
|
||
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
|
||
heat: number | null
|
||
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
|
||
entryStatus: EntryStatus
|
||
/** 成绩 id(服务端按它定位作废 / 退回;草稿行也有,未报名时才是 null)。 */
|
||
resultId: number | null
|
||
resultText: string
|
||
rank: number | null
|
||
status: ResultStatus
|
||
isTie: boolean
|
||
note: string
|
||
resultState: ResultState | null
|
||
original: string
|
||
}
|
||
|
||
interface Issue {
|
||
entryId: number | null
|
||
level: 'error' | 'warn'
|
||
message: string
|
||
}
|
||
|
||
interface PreviewRow {
|
||
teamId: number
|
||
name: string
|
||
current: number
|
||
delta: number
|
||
next: number
|
||
}
|
||
|
||
const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
|
||
{ value: 'ok', label: '有效' },
|
||
{ value: 'dns', label: 'DNS 弃赛' },
|
||
{ value: 'dq', label: 'DQ 取消' },
|
||
{ value: 'dnf', label: 'DNF 未完赛' },
|
||
{ value: 'withdrawn', label: '退赛' },
|
||
]
|
||
|
||
/**
|
||
* 成绩状态文案(下拉选项的反查表)。
|
||
*
|
||
* 已发布的行不再渲染下拉(disabled 的表单控件收不到点击,正是「点不动」的来源),
|
||
* 改用只读按钮显示当前状态,文案必须与下拉选项同源,免得两处各写一份慢慢说岔。
|
||
*/
|
||
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((option) => [option.value, option.label])) as Record<
|
||
ResultStatus,
|
||
string
|
||
>
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const data = useEventStore()
|
||
const auth = useAuthStore()
|
||
const toast = useToastStore()
|
||
|
||
const selectedEventId = ref<number | null>(null)
|
||
const detail = ref<Event | null>(null)
|
||
const selectedCategoryId = ref<number | null>(null)
|
||
const allRows = ref<EditableRow[]>([])
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const publishing = ref(false)
|
||
const unpublishing = ref(false)
|
||
const publishDialog = ref(false)
|
||
const unpublishDialog = ref(false)
|
||
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
|
||
const settlement = ref<SettlementStatus | null>(null)
|
||
const settling = ref(false)
|
||
const settleDialog = ref(false)
|
||
const focusedEntryId = ref<number | null>(null)
|
||
/** 成绩格专属的聚焦标记:悬浮提示只跟着它走,别让名次 / 状态格也弹成绩提示。 */
|
||
const focusedResultId = ref<number | null>(null)
|
||
/** 悬浮提示的弹出方向:贴近表格底部时向上翻,避免被 .mx-table-wrap 的 overflow 裁掉。 */
|
||
const hintPlacement = ref<'below' | 'above'>('below')
|
||
const tableRef = ref<HTMLElement | null>(null)
|
||
/** 批次筛选:默认「全部」,不改变现有的按组别录入习惯。 */
|
||
const selectedHeat = ref<HeatFilter>('all')
|
||
/** 表格行选择(批量设置批次用),存 entryId。 */
|
||
const selectedEntryIds = ref<number[]>([])
|
||
// 两个输入框都是 type="number",Vue 的 v-model 会把填进去的数字转成 number(清空才给空串),
|
||
// 如实标注 string | number:按下游当字符串用(.trim())会在渲染期抛 TypeError,整页白屏。
|
||
/** 「设为第 N 批」的批次号输入;空 = 用推荐的下一个批次号。 */
|
||
const heatInput = ref<string | number>('')
|
||
/** 「一键均分」的每批人数。 */
|
||
const heatSizeInput = ref<string | number>(String(DEFAULT_HEAT_SIZE))
|
||
const heatBusy = ref(false)
|
||
const splitDialog = ref(false)
|
||
const splitMessage = ref('')
|
||
/**
|
||
* 「补报名」弹窗:录入页这边取消过报名(批量移出 / 导入时漏报)后,回到这里就能把已有名单上的人
|
||
* 重新勾进当前小项的当前组别。弹窗内容直接复用基础数据 → 报名导入页的 EntryPicker。
|
||
*/
|
||
const supplementOpen = ref(false)
|
||
/** EntryPicker 读的是基础数据(运动员 / 参赛单位 / 小项组别),录入页平时不加载这一份,打开时补一次。 */
|
||
const baseData = useBaseData()
|
||
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
|
||
/** 未保存修改的确认框:切换项目与离开页面共用同一个,风格与发布确认一致。 */
|
||
const discardDialog = ref(false)
|
||
/** 用户确认丢弃后要执行的动作(切项目 / 重新跳转)。 */
|
||
let pendingDiscard: (() => void) | null = null
|
||
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
|
||
let requestSeq = 0
|
||
|
||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||
|
||
const eventGroups = computed(() => {
|
||
const groups = data.sports
|
||
.map((sport) => ({ key: sport.id, name: sport.name, events: data.events.filter((e) => e.sportId === sport.id) }))
|
||
.filter((group) => group.events.length > 0)
|
||
if (groups.length > 0) return groups
|
||
return [{ key: 0, name: '全部项目', events: data.events }]
|
||
})
|
||
|
||
const selectedEvent = computed(() => detail.value ?? (selectedEventId.value === null ? null : (data.eventById(selectedEventId.value) ?? null)))
|
||
const categories = computed(() => detail.value?.categories ?? [])
|
||
const channels = computed(() => detail.value?.channels ?? [])
|
||
/** 补报名弹窗要在标题下点名「补的是哪一组」,组别名按当前选中组别取。 */
|
||
const currentCategoryName = computed(
|
||
() => categories.value.find((category) => category.id === selectedCategoryId.value)?.name ?? '—',
|
||
)
|
||
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
|
||
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
|
||
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
|
||
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
|
||
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
|
||
|
||
/**
|
||
* 「团体名次」区块(设计 17 §八第 3 条):只有该组别配了「团体 + 手动名次」通道时才出现。
|
||
*
|
||
* 这类通道读的是 channel_manual_ranks(按单位录),与成绩表的个人名次是两套数据,
|
||
* 所以这里单开一块,而不是塞进成绩表格里。
|
||
*/
|
||
const showTeamRanks = computed(() => hasTeamManualChannel(channels.value))
|
||
/**
|
||
* 每个组别各自的编辑中行:组别间互不影响,切走再切回时未保存的输入还在
|
||
* (成绩表也是这个口径——切换组别不会丢正在编辑的成绩)。
|
||
*/
|
||
const teamRanksByCategory = ref(new Map<number, TeamRankRow[]>())
|
||
/** 录入接口随行下发的团体名次,按组别 id 缓存;0 号键放服务端没带 categoryId 的那一份。 */
|
||
const embeddedTeamRanks = ref(new Map<number, TeamRanksView>())
|
||
const teamRanksLoading = ref(false)
|
||
const teamRanksSaving = ref(false)
|
||
|
||
/** 当前组别的团体名次行(表格直接绑它)。 */
|
||
const teamRankRows = computed<TeamRankRow[]>(() => {
|
||
const categoryId = selectedCategoryId.value
|
||
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? [])
|
||
})
|
||
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
|
||
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
|
||
const teamRankIssues = computed(() => teamRankWarnings(teamRankRows.value))
|
||
/** 疑似笔误的行:给输入框描红,理由由 teamRankIssues 说清。 */
|
||
const teamRankIssueTeams = computed(() => teamRankFlaggedTeams(teamRankRows.value))
|
||
|
||
function unitOf(type: Event['resultType']): string {
|
||
if (type === 'time') return 's'
|
||
if (type === 'distance') return 'm'
|
||
if (type === 'count') return '次'
|
||
if (type === 'score') return 'pts'
|
||
return ''
|
||
}
|
||
|
||
const unit = computed(() => unitOf(selectedEvent.value?.resultType ?? 'none'))
|
||
|
||
/**
|
||
* 成绩格的示例文本。
|
||
*
|
||
* 必须带「例」字:光秃秃一个数字会被当成已填内容——用户实机就是这么误判成「填了 1:06」、
|
||
* 点发布才知道那一格其实是空的。
|
||
*/
|
||
const RESULT_SAMPLE: Record<string, string> = {
|
||
s: '1:06.33',
|
||
m: '12.34',
|
||
次: '12',
|
||
pts: '96.5',
|
||
}
|
||
|
||
const resultPlaceholder = computed(() => {
|
||
const sample = RESULT_SAMPLE[unit.value]
|
||
// resultType 为 none 时没有单位,给一个中性的数字例子,不留空白示例。
|
||
return sample === undefined ? '例 12' : `例 ${sample}`
|
||
})
|
||
|
||
/** 当前组别的全部行(批次筛选之前)。批次选项从它推导。 */
|
||
const categoryRows = computed(() =>
|
||
allRows.value.filter((row) => selectedCategoryId.value === null || row.categoryId === selectedCategoryId.value),
|
||
)
|
||
|
||
/** 批次下拉选项:全部 → 当前组别已有批次(去重升序)→ 未分批。 */
|
||
const heatChoices = computed(() => heatOptions(categoryRows.value))
|
||
|
||
/** 已有批次号列表,给「设为第 N 批」的输入框做候选(也允许直接写新批次号)。 */
|
||
const existingHeats = computed(() =>
|
||
[...new Set(categoryRows.value.map((row) => row.heat).filter((heat): heat is number => heat !== null))].sort(
|
||
(a, b) => a - b,
|
||
),
|
||
)
|
||
|
||
/** 推荐的下一个批次号:接着已有最大号往后排。 */
|
||
const nextHeat = computed(() => maxHeat(categoryRows.value) + 1)
|
||
|
||
/** 表格实际显示的行:组别 + 批次两层筛选;统计、防呆与发布口径都跟着它走。 */
|
||
const visibleRows = computed(() => filterRowsByHeat(categoryRows.value, selectedHeat.value))
|
||
|
||
/** 选中的行(只认当前可见的,避免筛选后误改看不见的行)。 */
|
||
const selectedRows = computed(() =>
|
||
visibleRows.value.filter((row) => selectedEntryIds.value.includes(row.entryId)),
|
||
)
|
||
|
||
const allVisibleSelected = computed(
|
||
() => visibleRows.value.length > 0 && selectedRows.value.length === visibleRows.value.length,
|
||
)
|
||
|
||
/** 「设为第 N 批」真正会用的批次号;填了非法值时为 null(按钮停用)。 */
|
||
const heatNumber = computed<number | null>(() => {
|
||
// 输入框填了数字后 v-model 给的是 number,不能直接 .trim()(会抛 TypeError → 整页白屏)。
|
||
const raw = String(heatInput.value).trim()
|
||
if (raw === '') return nextHeat.value
|
||
const value = Number(raw)
|
||
return Number.isInteger(value) && value >= 1 && value <= MAX_HEAT ? value : null
|
||
})
|
||
|
||
/** 「一键均分」的每批人数;非法时为 0,编排函数据此返回 null。 */
|
||
const heatSize = computed(() => {
|
||
const raw = String(heatSizeInput.value).trim()
|
||
const value = Number(raw)
|
||
return Number.isInteger(value) && value >= 1 ? value : 0
|
||
})
|
||
|
||
/**
|
||
* 一键均分的编排:只对未分批的行按显示顺序切片,批次号接着最大号往后排。
|
||
* 已分批的行不参与,手工编好的批次不会被冲掉。
|
||
*/
|
||
const splitPlan = computed(() => planHeatSplit(visibleRows.value, heatSize.value))
|
||
|
||
/**
|
||
* 提示文案里的作用域称谓:筛了批次就说「当前批次」,否则说「该组」。
|
||
*
|
||
* 统计与防呆都按当前可见行算,而发布 / 撤销发布仍按「小项 + 组别」整体生效,
|
||
* 称谓必须跟着可见行走,否则操作员会把「当前批次全发完」误读成整组都已发布。
|
||
*/
|
||
const scopeWord = computed(() => (selectedHeat.value === 'all' ? '该组' : '当前批次'))
|
||
const heatScoped = computed(() => selectedHeat.value !== 'all')
|
||
|
||
/**
|
||
* 报名项是否参与排名:与服务端 domain.EntryStatus.rankable() 同口径——
|
||
* 只有 registered 才计分,退赛 / dq / dns 一律 0 分且不占名次。
|
||
* 本地预览若不按这条过滤,退赛行会继续显示原来的名次与分数,和榜单对不上。
|
||
*/
|
||
function rankableRow(row: EditableRow): boolean {
|
||
return row.entryStatus === 'registered'
|
||
}
|
||
|
||
function scorableOf(rows: EditableRow[]): ScorableRow[] {
|
||
return rows.filter(rankableRow).map((row) => ({
|
||
entryId: row.entryId,
|
||
teamId: row.teamId,
|
||
athleteId: row.athleteId,
|
||
resultValue: parseResultValue(row.resultText, unit.value),
|
||
rank: row.rank,
|
||
status: row.status,
|
||
isTie: row.isTie,
|
||
// 联队份额随行喂给预览算分:单单位时它就是主单位独占,与升级前同口径。
|
||
shares: row.teamShares.map((share) => ({ teamId: share.teamId, weight: share.weight })),
|
||
}))
|
||
}
|
||
|
||
function scoreWith(rows: EditableRow[]) {
|
||
const event = selectedEvent.value
|
||
if (event === null) return null
|
||
return computeEventScore(scorableOf(rows), {
|
||
event,
|
||
category: categories.value.find((item) => item.id === selectedCategoryId.value) ?? null,
|
||
channels: channels.value,
|
||
profiles: data.profiles,
|
||
weight: weight.value,
|
||
})
|
||
}
|
||
|
||
/** 全量可见行(含未录行,但没成绩不参与排名)= 本次发布后服务端会算出的口径。 */
|
||
const score = computed(() => scoreWith(visibleRows.value))
|
||
|
||
/**
|
||
* 只含「已发布」行的一次独立算分:这是该组此刻在榜上的口径。
|
||
*
|
||
* 分批比赛时必须单独算:若拿全量口径里已发布行的分去扣,新一批会把老一批的名次挤下去,
|
||
* 扣掉的却是老一批在新口径下的(可能更低的)分,差值就不等于本次发布对榜单的真实影响。
|
||
*/
|
||
const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) => row.resultState === 'published')))
|
||
|
||
/**
|
||
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
|
||
*
|
||
* 用 rowTeamPoints(各通道原始分,个人通道已按权重折算)而不是服务端 result.points:
|
||
* 后者只覆盖已发布行,且编辑中的行服务端还没算分,两边口径无法混用。
|
||
*/
|
||
const publishedContribution = computed(() => {
|
||
const map = new Map<number, number>()
|
||
const rowTeamShares = publishedScore.value?.rowTeamShares
|
||
for (const row of visibleRows.value) {
|
||
if (row.resultState !== 'published') continue
|
||
// 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。
|
||
for (const part of rowTeamShares?.get(row.entryId) ?? []) {
|
||
map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points)
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
|
||
const preview = computed<PreviewRow[]>(() => {
|
||
const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints]))
|
||
const next = score.value?.teamPoints ?? new Map<number, number>()
|
||
return data.teams
|
||
.map((team) => {
|
||
const now = current.get(team.id) ?? 0
|
||
const delta = round((next.get(team.id) ?? 0) - (publishedContribution.value.get(team.id) ?? 0))
|
||
return { teamId: team.id, name: team.shortName ?? team.name, current: now, delta, next: round(now + delta) }
|
||
})
|
||
.sort((a, b) => b.next - a.next || a.name.localeCompare(b.name))
|
||
})
|
||
|
||
const maxDelta = computed(() => preview.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
|
||
const affectedTeams = computed(() => preview.value.filter((row) => row.delta !== 0).length)
|
||
/** 该组是否已有成绩发布过。分批比赛时可能只是部分发布,不能因此锁死整组。 */
|
||
const hasPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
|
||
/** 该组是否已全部发布:只有这时才需要先撤销发布才能继续录入。 */
|
||
const allPublished = computed(
|
||
() => visibleRows.value.length > 0 && visibleRows.value.every((row) => row.resultState === 'published'),
|
||
)
|
||
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
|
||
|
||
/**
|
||
* 当前 (小项, 组别) 的未结算状态:非 null 才提示。
|
||
*
|
||
* 延迟结算关闭时服务端返回空清单,这条提示天然不出现;读不到状态(示例数据 / 断线)时也不提示,
|
||
* 辅助信息不该打断现场录入。
|
||
*/
|
||
const pendingSettlement = computed<SettlementGroup | null>(() => {
|
||
if (selectedEventId.value === null || selectedCategoryId.value === null) return null
|
||
const groups = settlement.value?.groups ?? []
|
||
return (
|
||
groups.find(
|
||
(group) => group.eventId === selectedEventId.value && group.categoryId === selectedCategoryId.value,
|
||
) ?? null
|
||
)
|
||
})
|
||
/**
|
||
* dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。
|
||
*
|
||
* 团体名次的编辑也算在内:它是独立保存的(PUT team-ranks),
|
||
* 不并入这里的话切页会把它静默丢掉,而它同样要重算名次与积分。
|
||
*/
|
||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)) || teamRankDirty.value)
|
||
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
||
|
||
/** 丢弃确认框的明细:成绩与团体名次分开说,别让人看到「0 行」以为没有改动。 */
|
||
const discardDetails = computed(() => {
|
||
const details = [`未保存的成绩行:${dirtyCount.value} 行`]
|
||
if (teamRankDirty.value) details.push('还有未保存的团体名次(该组「团体名次」区块里)')
|
||
details.push('如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页')
|
||
return details
|
||
})
|
||
|
||
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
|
||
const leaveGuard = createLeaveGuard(() => dirty.value)
|
||
|
||
/** 有未保存修改时先弹确认框,确认后才执行动作(切换项目 / 离开页面共用)。 */
|
||
function guardDirty(action: () => void): void {
|
||
if (!dirty.value) {
|
||
action()
|
||
return
|
||
}
|
||
pendingDiscard = action
|
||
discardDialog.value = true
|
||
}
|
||
|
||
function confirmDiscard(): void {
|
||
const action = pendingDiscard
|
||
pendingDiscard = null
|
||
discardDialog.value = false
|
||
action?.()
|
||
}
|
||
|
||
function cancelDiscard(): void {
|
||
pendingDiscard = null
|
||
discardDialog.value = false
|
||
leaveGuard.cancel()
|
||
}
|
||
|
||
onBeforeRouteLeave((to) => {
|
||
if (leaveGuard.beforeLeave(to.fullPath)) return true
|
||
// 已拦下:等用户在这唯一一个确认框里做选择,确认后重放这次跳转。
|
||
pendingDiscard = () => {
|
||
const target = leaveGuard.confirm()
|
||
if (target === null) return
|
||
// 跳转结束后收回「已放行」:万一这次跳转被别处拦下,下次离开仍要重新确认。
|
||
void router.push(target).finally(() => leaveGuard.reset())
|
||
}
|
||
discardDialog.value = true
|
||
return false
|
||
})
|
||
|
||
/**
|
||
* 已发布的行由服务端锁定:直接改会让「榜单上是旧数、表格里是新数」。
|
||
* 退回草稿(reopen)后 state 变回 draft,这里随即放行——「修改本条」正是靠这一步解锁。
|
||
*/
|
||
function rowLocked(row: EditableRow): boolean {
|
||
return row.resultState === 'published'
|
||
}
|
||
|
||
/**
|
||
* 已发布(含已作废)行的锁定提示:这些格子改不了时,悬停要直接指出出路。
|
||
*
|
||
* `clickable` 指**这一格自己点得动**(成绩格 / 状态格:点一下弹退回草稿的确认框)——
|
||
* 名次 / 备注格仍不参与点击,只能说「点成绩格或右侧『修改』」,
|
||
* 否则提示就变成一句点不出来的空头承诺。
|
||
*
|
||
* 已发布行的出路有两条——退回这一条草稿,或整组「撤销发布」;已作废行的出路只有一条——「重新录入」。
|
||
*/
|
||
function lockedHint(row: EditableRow, clickable = false): string {
|
||
if (!rowLocked(row)) return ''
|
||
if (row.status === 'withdrawn') {
|
||
return '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
|
||
}
|
||
return clickable
|
||
? '已发布:点击这一格可退回草稿修改,改完需重新发布本组;也可以点右侧「修改」或整组「撤销发布」'
|
||
: '已发布:点成绩格或右侧「修改」退回草稿后可改,改完需重新发布本组'
|
||
}
|
||
|
||
/**
|
||
* 每行的行操作(修改本条 / 作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
|
||
*
|
||
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
|
||
*/
|
||
const rowActions = computed<Map<number, ResultActionTarget[]>>(() => {
|
||
const map = new Map<number, ResultActionTarget[]>()
|
||
for (const row of visibleRows.value) {
|
||
const targets = resultActionTargets(row)
|
||
if (targets.length > 0) map.set(row.entryId, targets)
|
||
}
|
||
return map
|
||
})
|
||
|
||
function rowActionsOf(row: EditableRow): ResultActionTarget[] {
|
||
return rowActions.value.get(row.entryId) ?? []
|
||
}
|
||
|
||
/**
|
||
* 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。
|
||
*
|
||
* 未录行只是「还没跑完」,在分批比赛时属于中性状态,不阻塞发布;
|
||
* 判定口径抽在 entryGuards 的纯函数里,审核页与录入页共用。
|
||
*/
|
||
const judgements = computed<Map<number, EntryRowJudgement>>(() => {
|
||
const map = new Map<number, EntryRowJudgement>()
|
||
for (const [index, row] of visibleRows.value.entries()) {
|
||
map.set(
|
||
row.entryId,
|
||
judgeEntryRow({
|
||
label: issueLabel(row, index),
|
||
resultText: row.resultText,
|
||
unit: unit.value,
|
||
status: row.status,
|
||
rank: row.rank,
|
||
needsRank: manualRank.value,
|
||
}),
|
||
)
|
||
}
|
||
return map
|
||
})
|
||
|
||
function rowJudgement(row: EditableRow): EntryRowJudgement | undefined {
|
||
return judgements.value.get(row.entryId)
|
||
}
|
||
|
||
// ── 聚焦成绩格的实时悬浮提示 ────────────────────────────────────────────────
|
||
|
||
/** 气泡大致高度 + 间距:判断输入框下方在滚动容器可视区里够不够放。 */
|
||
const HINT_SPACE = 46
|
||
|
||
/**
|
||
* 某一格此刻应有的提示;非 ok 状态没有成绩数值,不提示(与 judgeEntryRow 口径一致)。
|
||
* 纯函数只负责「显示什么」,定位与渲染留在模板里。
|
||
*/
|
||
function resultHintOf(row: EditableRow): ResultCellHint | null {
|
||
if (row.status !== 'ok' || rowLocked(row)) return null
|
||
return resultCellHint(row.resultText, unit.value)
|
||
}
|
||
|
||
/** 当前聚焦那一格的提示;只有成绩格聚焦时才非空,失焦立即收起。 */
|
||
const focusedHint = computed<ResultCellHint | null>(() => {
|
||
const id = focusedResultId.value
|
||
if (id === null) return null
|
||
const row = allRows.value.find((item) => item.entryId === id)
|
||
return row === undefined ? null : resultHintOf(row)
|
||
})
|
||
|
||
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
|
||
function resultTitle(row: EditableRow): string {
|
||
if (rowLocked(row)) {
|
||
// 已发布 / 已作废的行都锁着,但出路不同:统一由 lockedHint 说清该点哪个按钮。
|
||
// 成绩格自己点得动,用 clickable 那一份文案(「点击这一格」)。
|
||
return lockedHint(row, true)
|
||
}
|
||
const hint = resultHintOf(row)
|
||
return hint !== null && hint.level !== 'ok' ? hint.message : ''
|
||
}
|
||
|
||
/** 贴底就向上翻:拿输入框与滚动容器可视区比较,下方放不下、上方放得下时才改向上。 */
|
||
function updateHintPlacement(target: EventTarget | null): void {
|
||
const wrap = tableRef.value
|
||
if (wrap === null || !(target instanceof HTMLElement)) {
|
||
hintPlacement.value = 'below'
|
||
return
|
||
}
|
||
const cell = target.getBoundingClientRect()
|
||
const view = wrap.getBoundingClientRect()
|
||
const below = view.bottom - cell.bottom
|
||
const above = cell.top - view.top
|
||
hintPlacement.value = below < HINT_SPACE && above >= HINT_SPACE ? 'above' : 'below'
|
||
}
|
||
|
||
/** 只认成绩格的聚焦:名次 / 状态 / 备注格聚焦时不该弹成绩提示。 */
|
||
function onResultFocus(row: EditableRow, event: FocusEvent): void {
|
||
focusedEntryId.value = row.entryId
|
||
focusedResultId.value = row.entryId
|
||
updateHintPlacement(event.target)
|
||
}
|
||
|
||
function onResultBlur(): void {
|
||
focusedEntryId.value = null
|
||
focusedResultId.value = null
|
||
}
|
||
|
||
/** 每次输入都重测方向:内容变高 / 表格滚动后,「贴底」的判断要跟着更新。 */
|
||
function onResultInput(event: InputEvent): void {
|
||
updateHintPlacement(event.target)
|
||
}
|
||
|
||
/** 已填 / 未录 / 有错 三类的行数,工具栏与发布确认框都要用。 */
|
||
const stateCounts = computed(() => {
|
||
const counts = { filled: 0, missing: 0, error: 0 }
|
||
for (const row of visibleRows.value) {
|
||
const state = judgements.value.get(row.entryId)?.state ?? 'missing'
|
||
counts[state] += 1
|
||
}
|
||
return counts
|
||
})
|
||
|
||
/**
|
||
* 批次录入进度:分母是当前筛选范围的行数(选中某一批就只数这一批),
|
||
* 「已录」判据与发布防呆 / 行徽标共用 entryProgress → judgeEntryRow,不另写一套解析。
|
||
*/
|
||
const entryBatchProgress = computed(() =>
|
||
entryProgress({
|
||
rows: categoryRows.value,
|
||
filter: selectedHeat.value,
|
||
unit: unit.value,
|
||
needsRank: manualRank.value,
|
||
}),
|
||
)
|
||
|
||
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
|
||
const pendingRows = computed(() =>
|
||
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'filled'),
|
||
)
|
||
/** 还没录成绩、本次发布会跳过的行。 */
|
||
const unrecordedRows = computed(() =>
|
||
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'missing'),
|
||
)
|
||
|
||
const issues = computed<Issue[]>(() => {
|
||
const list: Issue[] = []
|
||
const event = selectedEvent.value
|
||
if (event === null) return list
|
||
const seenRanks = new Map<number, number>()
|
||
|
||
for (const row of visibleRows.value) {
|
||
const judged = rowJudgement(row)
|
||
if (judged === undefined) continue
|
||
if (judged.blocker !== null) {
|
||
list.push({ entryId: row.entryId, level: 'error', message: judged.blocker })
|
||
} else if (judged.warning !== null) {
|
||
list.push({ entryId: row.entryId, level: 'warn', message: judged.warning })
|
||
}
|
||
if (manualRank.value && row.status === 'ok' && row.rank !== null) {
|
||
seenRanks.set(row.rank, (seenRanks.get(row.rank) ?? 0) + 1)
|
||
}
|
||
}
|
||
|
||
for (const [rank, count] of seenRanks) {
|
||
if (count > 1) {
|
||
list.push({ entryId: null, level: 'warn', message: `名次 ${rank} 重复 ${count} 次,请确认是否并列(并列需勾选「并列」)` })
|
||
}
|
||
}
|
||
|
||
const record = recordForEvent.value
|
||
if (record !== null && record.resultValue > 0) {
|
||
for (const [index, row] of visibleRows.value.entries()) {
|
||
const value = parseResultValue(row.resultText, unit.value)
|
||
if (value === null || row.status !== 'ok') continue
|
||
const ratio = value / record.resultValue
|
||
if (ratio < 0.5 || ratio > 2) {
|
||
list.push({
|
||
entryId: row.entryId,
|
||
level: 'warn',
|
||
message: `${issueLabel(row, index)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return list
|
||
})
|
||
|
||
const errors = computed(() => issues.value.filter((issue) => issue.level === 'error'))
|
||
const warnings = computed(() => issues.value.filter((issue) => issue.level === 'warn'))
|
||
/** 提醒可能很多(未录行逐条列出),列表只展开前若干条,避免把页面撑爆。 */
|
||
const MAX_LISTED_ISSUES = 12
|
||
const listedIssues = computed(() => [...errors.value, ...warnings.value].slice(0, MAX_LISTED_ISSUES))
|
||
const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnings.value.length - listedIssues.value.length))
|
||
|
||
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
|
||
const publishMessage = computed(() => {
|
||
const pending = pendingRows.value.length
|
||
const missing = unrecordedRows.value.length
|
||
const head =
|
||
missing > 0
|
||
? `${scopeWord.value}还有 ${missing} 人没有成绩,本次将发布已填的 ${pending} 人;其余保持未发布,补录后再发布即可。`
|
||
: `本次将发布${scopeWord.value}全部 ${pending} 人。`
|
||
// 批次只是展示 / 录入分组:发布仍按「小项 + 组别」整体生效,
|
||
// 所以筛了某个批次时要把这件事说清楚,避免以为只发这一批。
|
||
const scope = heatScoped.value
|
||
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
|
||
: ''
|
||
// 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。
|
||
const tail =
|
||
settlement.value?.liveOnPublish === false
|
||
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。'
|
||
: '发布后立即上屏并重算积分榜。'
|
||
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}`
|
||
})
|
||
|
||
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
||
function rowLabel(row: EditableRow): string {
|
||
return entryRowLabel(row)
|
||
}
|
||
|
||
/** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */
|
||
function issueLabel(row: EditableRow, index: number): string {
|
||
const name = rowLabel(row)
|
||
return `第 ${index + 1} 行 ${name === '' ? '(未署名)' : name}`
|
||
}
|
||
|
||
function round(value: number): number {
|
||
return Number(value.toFixed(2))
|
||
}
|
||
|
||
/** 空缺成绩的快照必须与 rowDirty 拼出的默认行一致,否则一进页面就亮「已修改」。 */
|
||
function snapshotOf(entry: EntryWithResult): string {
|
||
const result = entry.result
|
||
if (result === null) return ['', '', 'ok', '0', ''].join('|')
|
||
return [result.resultText, result.rank ?? '', result.status, result.isTie ? '1' : '0', result.note ?? ''].join('|')
|
||
}
|
||
|
||
function toRow(entry: EntryWithResult): EditableRow {
|
||
const result = entry.result
|
||
const teamName = entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位'
|
||
const row: EditableRow = {
|
||
entryId: entry.id,
|
||
categoryId: entry.categoryId,
|
||
bib: entry.bib,
|
||
teamId: entry.teamId,
|
||
athleteId: entry.athleteId,
|
||
teamName,
|
||
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
|
||
// 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。
|
||
teamShares: normalizeTeamShares(entry.teamShares, {
|
||
teamId: entry.teamId,
|
||
weight: 1,
|
||
teamCode: entry.teamCode ?? data.teamById(entry.teamId)?.code ?? '',
|
||
teamName,
|
||
}),
|
||
athleteName: entry.athleteName ?? '',
|
||
memberNames: entry.memberNames,
|
||
heat: entry.heat,
|
||
entryStatus: entry.status,
|
||
resultId: result?.id ?? null,
|
||
resultText: result?.resultText ?? '',
|
||
rank: result?.rank ?? null,
|
||
status: result?.status ?? 'ok',
|
||
isTie: result?.isTie ?? false,
|
||
note: result?.note ?? '',
|
||
resultState: result?.state ?? null,
|
||
original: '',
|
||
}
|
||
row.original = snapshotOf(entry)
|
||
return row
|
||
}
|
||
|
||
function rowDirty(row: EditableRow): boolean {
|
||
return row.original !== [row.resultText, row.rank ?? '', row.status, row.isTie ? '1' : '0', row.note].join('|')
|
||
}
|
||
|
||
/** 名次输入框清空时 v-model.number 会给出空串,必须在入口就归一为 null。 */
|
||
function rankInputValue(event: InputEvent): number | null {
|
||
const raw = (event.target as HTMLInputElement).value.trim()
|
||
if (raw === '') return null
|
||
const value = Number(raw)
|
||
return Number.isFinite(value) ? Math.trunc(value) : null
|
||
}
|
||
|
||
function rankOf(row: EditableRow): number | null {
|
||
if (manualRank.value) return row.rank
|
||
return score.value?.ranks.get(row.entryId) ?? null
|
||
}
|
||
|
||
function pointsOf(row: EditableRow): number {
|
||
return round(score.value?.rowPoints.get(row.entryId) ?? 0)
|
||
}
|
||
|
||
function issueFor(entryId: number): Issue | undefined {
|
||
return issues.value.find((issue) => issue.entryId === entryId)
|
||
}
|
||
|
||
/**
|
||
* 切换组别:批次筛选与行选择都回到原点。
|
||
*
|
||
* 批次是「组别之下」的概念,换组别后旧批次号没有意义;选择留着会让人误以为
|
||
* 还能对看不见的行做批量设置。
|
||
*/
|
||
function selectCategory(id: number): void {
|
||
if (selectedCategoryId.value === id) return
|
||
selectedCategoryId.value = id
|
||
selectedHeat.value = 'all'
|
||
selectedEntryIds.value = []
|
||
void loadTeamRanks()
|
||
}
|
||
|
||
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
|
||
function selectEvent(id: number): void {
|
||
if (selectedEventId.value === id && detail.value !== null) return
|
||
guardDirty(() => void loadEvent(id))
|
||
}
|
||
|
||
/** 真正加载项目:只在确认框放行后调用,加载失败时保持当前项目不变。 */
|
||
async function loadEvent(id: number): Promise<void> {
|
||
selectedEventId.value = id
|
||
const seq = (requestSeq += 1)
|
||
loading.value = true
|
||
try {
|
||
// 用 entryBoard 而不是 entries:同一次请求就会带回团体手工名次(设计 17 §七),
|
||
// 不必再多发一轮 GET team-ranks。
|
||
const [event, board, records] = await Promise.all([
|
||
adminApi.eventDetail(id),
|
||
adminApi.entryBoard(id),
|
||
adminApi.records(),
|
||
])
|
||
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
|
||
if (seq !== requestSeq) return
|
||
detail.value = event
|
||
avgMode.value = loadAvgMode(id, event.avgMode)
|
||
// 换项目等于换一份数据:团体名次的草稿与随行视图都先清掉,再归位新的一份。
|
||
// 归位必须赶在下面改 selectedCategoryId 之前——组别 watch 会立刻按新组别取数据。
|
||
teamRanksByCategory.value = new Map()
|
||
collectEmbeddedTeamRanks(board.teamRanks)
|
||
selectedCategoryId.value = event.categories?.[0]?.id ?? null
|
||
selectedHeat.value = 'all'
|
||
selectedEntryIds.value = []
|
||
allRows.value = board.entries.map(toRow)
|
||
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
|
||
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
|
||
// 组别已定、随行视图已归位,这时候取团体名次:有随行数据就走缓存,否则发一次 GET。
|
||
// 不用 watch(selectedCategoryId) 是为了不依赖「赋值后回调晚于归位」这种时序巧合。
|
||
void loadTeamRanks()
|
||
} catch (caught) {
|
||
if (seq !== requestSeq) return
|
||
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
|
||
} finally {
|
||
if (seq === requestSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换平均值口径:立即整体回写该小项(PUT /api/events/{id}),成功后 toast。
|
||
*
|
||
* 与成绩草稿的保存完全分离——这里既不改录入行,也不走 saveDraft;
|
||
* 失败时把选择退回原值,避免页面显示的口径与服务端实际生效的口径不一致。
|
||
*/
|
||
async function pickAvgMode(mode: AvgMode): Promise<void> {
|
||
const event = detail.value
|
||
if (event === null || !canWrite.value) return
|
||
if (data.isMock) {
|
||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报;与「发布」一致直接停用。
|
||
toast.error('示例数据模式不能保存口径', '后端未就绪,改动不会写入真实赛事')
|
||
return
|
||
}
|
||
const next = selectAvgMode(avgMode.value, mode)
|
||
avgMode.value = next.state
|
||
if (!next.shouldSave) return
|
||
try {
|
||
const saved = await adminApi.client.events.update(event.id, eventInputOf(event, { avgMode: mode }))
|
||
avgMode.value = settleAvgMode(avgMode.value, true)
|
||
// 服务端返回的是小项本身,组别 / 通道仍以当前页加载的详情为准。
|
||
detail.value = {
|
||
...saved,
|
||
categories: saved.categories ?? event.categories,
|
||
channels: saved.channels ?? event.channels,
|
||
}
|
||
await data.refreshEvents()
|
||
toast.success(
|
||
'平均值口径已保存',
|
||
mode === 'all' ? '没成绩的按 0 计入平均' : '只算有有效成绩的人',
|
||
)
|
||
} catch (caught) {
|
||
avgMode.value = settleAvgMode(avgMode.value, false)
|
||
toast.error('保存平均值口径失败', caught instanceof Error ? caught.message : null)
|
||
}
|
||
}
|
||
|
||
async function reload(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
const id = selectedEventId.value
|
||
const seq = requestSeq
|
||
loading.value = true
|
||
try {
|
||
const entries = await adminApi.entries(id)
|
||
if (seq !== requestSeq) return
|
||
allRows.value = entries.map(toRow)
|
||
} catch (caught) {
|
||
if (seq !== requestSeq) return
|
||
toast.error(caught instanceof Error ? caught.message : '重新加载失败')
|
||
} finally {
|
||
if (seq === requestSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
// ── 补报名(复用基础数据 → 报名导入页的「从已有名单勾选」) ────────────────────
|
||
|
||
/**
|
||
* 打开补报名弹窗。
|
||
*
|
||
* 三道前置检查与批量操作、行操作同一口径:示例数据模式写不进真实赛事、只读身份不能写、
|
||
* 有未保存的成绩时先落草稿——提交成功后要整表重拉,没落草稿的编辑会被服务端口径覆盖掉。
|
||
*/
|
||
async function openSupplement(): Promise<void> {
|
||
if (data.isMock) {
|
||
const notice = sampleDataBlockedNotice('补报名')
|
||
toast.error(notice.title, notice.detail)
|
||
return
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可报名', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return
|
||
}
|
||
if (dirty.value) {
|
||
toast.error('有未保存的修改', '补报名会刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||
return
|
||
}
|
||
supplementOpen.value = true
|
||
// 勾选名单要的是基础数据这一份(运动员 / 参赛单位 / 组别),load 自带缓存,重复打开不会重复请求。
|
||
await baseData.load()
|
||
}
|
||
|
||
/**
|
||
* 补报名成功:重拉当前小项的报名项。
|
||
*
|
||
* 服务端建完报名会连带重算名次与积分,整表重载才能同时拿到新行与新口径;只在本地拼一行会留下
|
||
* 「表格里有人、名次还是旧的」这种半成品。弹窗里预选的就是当前小项,所以正常情况下提交的
|
||
* eventId 就是它;万一在弹窗里换了别的小项,本页不动(那是另一张表的事)。
|
||
*/
|
||
async function onSupplementSubmitted(eventId: number, categoryId: number | null): Promise<void> {
|
||
if (eventId !== selectedEventId.value) return
|
||
await reload()
|
||
if (categoryId === null || categoryId === selectedCategoryId.value) {
|
||
toast.success('补报名成功,本组名单已刷新', '刚补上的人已经排进本组表格')
|
||
}
|
||
}
|
||
|
||
// ── 成员单位与份额(联队) ────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 份额编辑器(devdocs/19)。
|
||
*
|
||
* 打开时把这条报名**当前**的成员单位摆出来:单单位报名看到的就是孤零零一个主单位、
|
||
* 权重留空,什么都不改直接关掉等于没动过——常规报名流程一点没被碰到。
|
||
* 需要时在弹窗里加单位、填权重(留空 = 均分),保存后服务端当场重算本组并推榜。
|
||
*
|
||
* `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换
|
||
* 发出去(服务端会照着做,只是白重算一遍本组)。
|
||
*/
|
||
interface ShareEditor {
|
||
entryId: number
|
||
/** 展示用的报名身份:运动员 / 队 + 号码布,让操作员确认改的是哪一条。 */
|
||
label: string
|
||
/** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */
|
||
primaryTeamId: number
|
||
primaryName: string
|
||
original: ShareDraftRow[]
|
||
draft: ShareDraftRow[]
|
||
saving: boolean
|
||
error: string | null
|
||
}
|
||
|
||
const shareEditor = ref<ShareEditor | null>(null)
|
||
const pendingShareTeamId = ref('')
|
||
|
||
const shareIssues = computed(() => {
|
||
const editor = shareEditor.value
|
||
if (editor === null) return []
|
||
return shareDraftIssues(editor.draft, editor.primaryTeamId, editor.primaryName)
|
||
})
|
||
const shareChanged = computed(() => {
|
||
const editor = shareEditor.value
|
||
return editor === null ? false : shareDraftDirty(editor.draft, editor.original)
|
||
})
|
||
const shareView = computed(() => {
|
||
const editor = shareEditor.value
|
||
return editor === null ? [] : shareDraftView(editor.draft)
|
||
})
|
||
/** 比例预览:`第一分公司 67% · 第二分公司 33%`,保存前就看得到分怎么拆。 */
|
||
const shareRatioText = computed(() => shareView.value.map(shareText).join(' · '))
|
||
/** 还能加进来的单位:不在成员里的都行(已停用的可以,假删除的不要)。 */
|
||
const shareCandidates = computed(() => {
|
||
const editor = shareEditor.value
|
||
return editor === null ? [] : selectableShareTeams(data.teams, editor.draft)
|
||
})
|
||
const canSaveShares = computed(
|
||
() =>
|
||
canWrite.value &&
|
||
shareEditor.value !== null &&
|
||
!shareEditor.value.saving &&
|
||
shareChanged.value &&
|
||
shareIssues.value.length === 0,
|
||
)
|
||
|
||
function openShareEditor(row: EditableRow): void {
|
||
pendingShareTeamId.value = ''
|
||
shareEditor.value = {
|
||
entryId: row.entryId,
|
||
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
|
||
primaryTeamId: row.teamId,
|
||
primaryName: row.teamName,
|
||
original: shareDraftOf(row),
|
||
draft: shareDraftOf(row),
|
||
saving: false,
|
||
error: null,
|
||
}
|
||
}
|
||
|
||
function closeShareEditor(): void {
|
||
shareEditor.value = null
|
||
pendingShareTeamId.value = ''
|
||
}
|
||
|
||
function addShareMember(): void {
|
||
const editor = shareEditor.value
|
||
if (editor === null) return
|
||
const team = data.teams.find((item) => String(item.id) === pendingShareTeamId.value)
|
||
if (team === undefined) return
|
||
editor.draft = addShareDraftTeam(editor.draft, team)
|
||
}
|
||
|
||
function dropShareMember(teamId: number): void {
|
||
const editor = shareEditor.value
|
||
if (editor === null || teamId === editor.primaryTeamId) return
|
||
editor.draft = removeShareDraftTeam(editor.draft, teamId)
|
||
}
|
||
|
||
function editShareWeight(teamId: number, event: InputEvent): void {
|
||
const editor = shareEditor.value
|
||
if (editor === null) return
|
||
const input = event.target
|
||
if (!(input instanceof HTMLInputElement)) return
|
||
editor.draft = setShareDraftWeight(editor.draft, teamId, input.value)
|
||
}
|
||
|
||
/**
|
||
* 保存份额:整组替换一条报名的成员单位。
|
||
*
|
||
* 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉,
|
||
* 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。
|
||
* 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。
|
||
*/
|
||
async function saveShareEditor(): Promise<void> {
|
||
const editor = shareEditor.value
|
||
if (editor === null || !canSaveShares.value) return
|
||
if (data.isMock) {
|
||
const notice = sampleDataBlockedNotice('保存成员单位')
|
||
toast.error(notice.title, notice.detail)
|
||
return
|
||
}
|
||
editor.saving = true
|
||
editor.error = null
|
||
const payload = shareDraftPayload(editor.draft)
|
||
try {
|
||
const outcome = await submitEntryTeamShares(editor.entryId, payload)
|
||
const shares = outcome.teamShares ?? payload
|
||
const row = allRows.value.find((item) => item.entryId === editor.entryId)
|
||
if (row !== undefined) {
|
||
const fallbackName = row.teamName
|
||
row.teamShares = normalizeTeamShares(shares, {
|
||
teamId: row.teamId,
|
||
weight: 1,
|
||
teamCode: row.teamShares[0]?.teamCode ?? '',
|
||
teamName: fallbackName,
|
||
})
|
||
}
|
||
closeShareEditor()
|
||
void data.refreshStandings()
|
||
toast.success(
|
||
'成员单位已保存',
|
||
isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位',
|
||
)
|
||
} catch (caught) {
|
||
editor.error = caught instanceof Error ? caught.message : '保存成员单位失败'
|
||
} finally {
|
||
editor.saving = false
|
||
}
|
||
}
|
||
|
||
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
|
||
|
||
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
|
||
function collectEmbeddedTeamRanks(views: readonly TeamRanksView[]): void {
|
||
const next = new Map<number, TeamRanksView>()
|
||
for (const view of views) next.set(view.categoryId ?? 0, view)
|
||
embeddedTeamRanks.value = next
|
||
}
|
||
|
||
/**
|
||
* 取某个组别的视图:优先用录入接口随行下发的那一份(省一轮请求)。
|
||
*
|
||
* 服务端不带 categoryId 时无从归位,只有一个组别的事件才敢直接用它——
|
||
* 猜错组别会把别的组的名次显示成本组的名次,比多一次请求危险得多,宁可回退到 GET。
|
||
*/
|
||
function embeddedTeamRanksFor(categoryId: number): TeamRanksView | null {
|
||
const exact = embeddedTeamRanks.value.get(categoryId)
|
||
if (exact !== undefined) return exact
|
||
return categories.value.length === 1 ? (embeddedTeamRanks.value.get(0) ?? null) : null
|
||
}
|
||
|
||
/** 把最新视图写进缓存:无 categoryId 的旧视图可能就是这个组别的,一并清掉免得盖过新值。 */
|
||
function cacheTeamRanksView(categoryId: number, view: TeamRanksView): void {
|
||
const next = new Map(embeddedTeamRanks.value)
|
||
next.delete(0)
|
||
next.set(categoryId, view)
|
||
embeddedTeamRanks.value = next
|
||
}
|
||
|
||
/**
|
||
* 载入当前组别的团体名次。
|
||
*
|
||
* 已经有未保存输入的组别不重载——否则切走再切回会把操作员刚填的名次冲掉。
|
||
*/
|
||
async function loadTeamRanks(): Promise<void> {
|
||
const eventId = selectedEventId.value
|
||
const categoryId = selectedCategoryId.value
|
||
if (eventId === null || categoryId === null || !showTeamRanks.value) {
|
||
// 该组别没有团体手工通道:即使以前填过也不显示(服务端不会读它)。
|
||
return
|
||
}
|
||
const draft = teamRanksByCategory.value.get(categoryId)
|
||
if (draft !== undefined && teamRanksDirty(draft)) return
|
||
const embedded = embeddedTeamRanksFor(categoryId)
|
||
if (embedded !== null) {
|
||
writeTeamRanks(categoryId, teamRankRowsOf(embedded, data.teams))
|
||
return
|
||
}
|
||
const seq = requestSeq
|
||
teamRanksLoading.value = true
|
||
try {
|
||
const view = await adminApi.teamRanks(eventId, categoryId)
|
||
if (seq !== requestSeq || categoryId !== selectedCategoryId.value) return
|
||
cacheTeamRanksView(categoryId, view)
|
||
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
|
||
} catch (caught) {
|
||
if (seq !== requestSeq) return
|
||
// 读不到就按空表处理:这是辅助区块,不能挡住成绩录入。
|
||
writeTeamRanks(categoryId, [])
|
||
toast.error('读取团体名次失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
if (seq === requestSeq) teamRanksLoading.value = false
|
||
}
|
||
}
|
||
|
||
function writeTeamRanks(categoryId: number, rows: TeamRankRow[]): void {
|
||
const next = new Map(teamRanksByCategory.value)
|
||
next.set(categoryId, rows)
|
||
teamRanksByCategory.value = next
|
||
}
|
||
|
||
/**
|
||
* 保存当前组别的团体名次(PUT .../team-ranks,整组替换)。
|
||
*
|
||
* 保存会重算该组的名次与积分(成绩表的名次列、积分预览、榜单都会变),
|
||
* 所以成功后按「发布」同一套流程整页刷新一次;服务端回的就是保存后的视图,直接回填。
|
||
*/
|
||
async function saveTeamRanks(): Promise<void> {
|
||
const eventId = selectedEventId.value
|
||
const categoryId = selectedCategoryId.value
|
||
if (eventId === null || categoryId === null) return
|
||
if (data.isMock) {
|
||
// 与草稿 / 发布同一口径:示例数据模式下写接口没有后端可落库,提示成功等于谎报。
|
||
toast.error('示例数据模式不能保存团体名次', '后端未就绪,改动不会写入真实赛事')
|
||
return
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return
|
||
}
|
||
if (teamRankRows.value.length === 0) {
|
||
toast.info('没有可录入的参赛单位')
|
||
return
|
||
}
|
||
teamRanksSaving.value = true
|
||
try {
|
||
const view = await adminApi.saveTeamRanks(eventId, categoryId, teamRanksPayload(teamRankRows.value))
|
||
cacheTeamRanksView(categoryId, view)
|
||
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
|
||
toast.success('团体名次已保存', '该组名次与积分榜会按新名次重算')
|
||
await reloadAfterSettlementChange()
|
||
} catch (caught) {
|
||
toast.error('保存团体名次失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
teamRanksSaving.value = false
|
||
}
|
||
}
|
||
|
||
function buildDraftPayload(): DraftResultInput[] {
|
||
// 已发布行由服务端锁定,不能再存草稿(服务端会 409 STATE_INVALID);
|
||
// 分批发布时本组会同时存在「已发布」与「未发布」的行,只提交未发布的那些。
|
||
return visibleRows.value
|
||
.filter((row) => row.resultState !== 'published')
|
||
.map((row) => ({
|
||
entryId: row.entryId,
|
||
rank: manualRank.value ? row.rank : null,
|
||
resultText: row.resultText.trim(),
|
||
resultValue: parseResultValue(row.resultText, unit.value),
|
||
// 不传 unit 时服务端会把已有单位覆盖成 NULL,大屏 100 米就会显示成 66.33。
|
||
unit: unit.value,
|
||
status: row.status,
|
||
isTie: row.isTie,
|
||
note: row.note.trim() === '' ? null : row.note.trim(),
|
||
}))
|
||
}
|
||
|
||
async function saveDraft(silent = false): Promise<boolean> {
|
||
if (selectedEventId.value === null) return false
|
||
if (saving.value) return false
|
||
if (data.isMock) {
|
||
// 与发布 / 批次 / 团体名次同一口径:示例数据模式下写接口没有后端可落库,
|
||
// 照发请求只会得到一句网络错误,操作员会以为是自己手滑而不是「这台机没连服务端」。
|
||
toast.error('示例数据模式不能保存草稿', '后端未就绪,草稿不会写入真实赛事')
|
||
return false
|
||
}
|
||
if (allPublished.value) {
|
||
// 可见行全部 published 后服务端会拒绝这次草稿(载荷为空),必须先撤销发布。
|
||
toast.error(`${scopeWord.value}已全部发布`, '修改前请先撤销发布')
|
||
return false
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return false
|
||
}
|
||
const payload = buildDraftPayload()
|
||
if (payload.length === 0) {
|
||
// 没有未发布的行可提交:服务端会以「results 不能为空」拒绝,
|
||
// 这里先给出明确说明,别把一次无意义点击变成一条服务端报错。
|
||
toast.info(allPublished.value ? `${scopeWord.value}已全部发布` : `${scopeWord.value}还没有报名项`)
|
||
return false
|
||
}
|
||
saving.value = true
|
||
try {
|
||
await adminApi.saveDraft({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
results: payload,
|
||
})
|
||
await reload()
|
||
if (!silent) toast.success('草稿已保存')
|
||
return true
|
||
} catch (caught) {
|
||
toast.error('保存草稿失败', caught instanceof Error ? caught.message : null)
|
||
return false
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function requestPublish(): void {
|
||
if (selectedEventId.value === null) return
|
||
if (data.isMock) {
|
||
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
|
||
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
|
||
return
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return
|
||
}
|
||
if (allPublished.value) {
|
||
toast.error(`${scopeWord.value}已全部发布`, '如需修改请先撤销发布')
|
||
return
|
||
}
|
||
if (pendingRows.value.length === 0) {
|
||
toast.error('没有可发布的成绩', '未录成绩的行会被跳过;请先录入至少一条成绩再发布')
|
||
return
|
||
}
|
||
if (errors.value.length > 0) {
|
||
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
|
||
return
|
||
}
|
||
publishDialog.value = true
|
||
}
|
||
|
||
async function confirmPublish(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
publishing.value = true
|
||
const published = pendingRows.value.length
|
||
const missing = unrecordedRows.value.length
|
||
try {
|
||
// 发布作用于服务端已保存的草稿,所以必须先整组落草稿再发布(07 第 3 节)。
|
||
// 只提交未发布的行,已发布行由服务端锁定。
|
||
await adminApi.saveDraft({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
results: buildDraftPayload(),
|
||
})
|
||
await adminApi.publish({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
})
|
||
publishDialog.value = false
|
||
await reloadAfterSettlementChange()
|
||
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
|
||
const deferred = settlement.value?.liveOnPublish === false
|
||
const leftover =
|
||
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null
|
||
if (deferred && pendingSettlement.value !== null) {
|
||
toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`)
|
||
} else {
|
||
toast.success('发布成功,积分榜已更新', leftover)
|
||
}
|
||
} catch (caught) {
|
||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
publishing.value = false
|
||
}
|
||
}
|
||
|
||
async function confirmUnpublish(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
if (data.isMock) {
|
||
// 与发布同一口径:示例数据模式下 unpublish 只改内存,提示「已撤销发布」等于谎报。
|
||
toast.error('示例数据模式不能撤销发布', '后端未就绪,撤销发布不会写入真实赛事')
|
||
unpublishDialog.value = false
|
||
return
|
||
}
|
||
unpublishing.value = true
|
||
try {
|
||
await adminApi.unpublish({
|
||
eventId: selectedEventId.value,
|
||
categoryId: selectedCategoryId.value ?? undefined,
|
||
})
|
||
unpublishDialog.value = false
|
||
await reloadAfterSettlementChange()
|
||
toast.warning('已撤销发布,大屏将回退该组成绩')
|
||
} catch (caught) {
|
||
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
unpublishing.value = false
|
||
}
|
||
}
|
||
|
||
// ── 分批结算(延迟结算下的「榜单还没反映这批」提示 + 人工兜底) ────────────────
|
||
|
||
/** 读当前 (小项, 组别) 的结算状态;失败时按「没有提示」处理,不打断录入。 */
|
||
async function refreshSettlement(): Promise<void> {
|
||
const id = selectedEventId.value
|
||
const categoryId = selectedCategoryId.value
|
||
if (id === null) {
|
||
settlement.value = null
|
||
return
|
||
}
|
||
try {
|
||
const status = await settlementApi.status(id, categoryId)
|
||
// 请求期间可能已经切了项目 / 组别,旧响应不许覆盖新状态。
|
||
if (id !== selectedEventId.value || categoryId !== selectedCategoryId.value) return
|
||
settlement.value = status
|
||
} catch {
|
||
settlement.value = null
|
||
}
|
||
}
|
||
|
||
/** 发布 / 撤销 / 结算之后一起刷新:榜单、项目列表与结算状态。 */
|
||
async function reloadAfterSettlementChange(): Promise<void> {
|
||
await reload()
|
||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||
await refreshSettlement()
|
||
}
|
||
|
||
/** 立即结算:这是「有人永远录不出成绩」时的人工兜底,成功后榜单按已发布部分更新。 */
|
||
async function confirmSettle(): Promise<void> {
|
||
if (selectedEventId.value === null) return
|
||
settling.value = true
|
||
try {
|
||
await settlementApi.settle(selectedEventId.value, selectedCategoryId.value ?? undefined)
|
||
settleDialog.value = false
|
||
await reloadAfterSettlementChange()
|
||
toast.success('已按当前已发布成绩结算榜单', '未录完的人仍可继续补录,录完后会再自动结算')
|
||
} catch (caught) {
|
||
toast.error('立即结算失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
settling.value = false
|
||
}
|
||
}
|
||
|
||
// ── 批次(录入 / 展示分组,不参与算分) ──────────────────────────────────────
|
||
|
||
/** 批量设置批次的共同前置检查;不通过时给出具体原因。 */
|
||
function canEditHeat(): boolean {
|
||
if (data.isMock) {
|
||
toast.error('示例数据模式不能设置批次', '后端未就绪,批次不会写入真实赛事')
|
||
return false
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可设置批次', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 顺序提交若干批「设置批次」请求(每个批次号一次 PUT /api/entries/heat)。
|
||
*
|
||
* 之所以按批次号分批调用:接口的契约是「一批 entry 统一设成同一个 heat」。
|
||
* 成功后只就地更新本地行的批次,不整表重载——重载会把还没保存的成绩草稿一起丢掉。
|
||
*/
|
||
async function submitHeatAllocations(allocations: HeatAllocation[], successMessage: string): Promise<void> {
|
||
if (!canEditHeat() || allocations.length === 0) return
|
||
heatBusy.value = true
|
||
let updated = 0
|
||
let failure: Error | null = null
|
||
for (const item of allocations) {
|
||
try {
|
||
const result = await adminApi.setEntriesHeat({ entryIds: item.entryIds, heat: item.heat })
|
||
allRows.value = applyHeatToRows(allRows.value, result.entryIds, result.heat)
|
||
const done = new Set(result.entryIds)
|
||
selectedEntryIds.value = selectedEntryIds.value.filter((id) => !done.has(id))
|
||
updated += result.updated
|
||
} catch (caught) {
|
||
// 中途失败就停:后面的批次依赖同一份显示顺序,硬撑下去只会编出更乱的批次。
|
||
failure = caught instanceof Error ? caught : new Error('设置批次失败')
|
||
break
|
||
}
|
||
}
|
||
heatBusy.value = false
|
||
if (failure !== null) {
|
||
toast.error('设置批次失败', failure.message)
|
||
return
|
||
}
|
||
toast.success(successMessage, `${updated} 行已更新`)
|
||
}
|
||
|
||
/** 把选中的行设为输入框里的批次号。 */
|
||
async function applyHeatToSelected(): Promise<void> {
|
||
const heat = heatNumber.value
|
||
if (heat === null) {
|
||
toast.error('批次号不合法', `请填 1-${MAX_HEAT} 的整数`)
|
||
return
|
||
}
|
||
await submitHeatAllocations(
|
||
[{ heat, entryIds: selectedRows.value.map((row) => row.entryId) }],
|
||
`已设为第 ${heat} 批`,
|
||
)
|
||
}
|
||
|
||
/** 把选中的行清除为「未分批」。 */
|
||
async function clearHeatForSelected(): Promise<void> {
|
||
await submitHeatAllocations(
|
||
[{ heat: null, entryIds: selectedRows.value.map((row) => row.entryId) }],
|
||
'已清除批次',
|
||
)
|
||
}
|
||
|
||
/** 表头复选框:勾选 = 并入当前可见行,取消 = 只移除当前可见行。 */
|
||
// 参数类型写 globalThis.Event:本文件里的 Event 是共享类型里的小项。
|
||
function toggleAllVisible(event: globalThis.Event): void {
|
||
const checked = (event.target as HTMLInputElement).checked
|
||
const visible = new Set(visibleRows.value.map((row) => row.entryId))
|
||
selectedEntryIds.value = checked
|
||
? [...new Set([...selectedEntryIds.value, ...visible])]
|
||
: selectedEntryIds.value.filter((id) => !visible.has(id))
|
||
}
|
||
|
||
function clearSelection(): void {
|
||
selectedEntryIds.value = []
|
||
}
|
||
|
||
// ── 批量退赛 / 恢复 / 移出报名(作用范围 = 勾选行) ────────────────────────────
|
||
|
||
/** 批量操作的确认框状态;计划由 planEntryBatch 先算好,确认框只负责展示。 */
|
||
const batchPlan = ref<EntryBatchPlan | null>(null)
|
||
const batchDialog = ref(false)
|
||
const batchBusy = ref(false)
|
||
|
||
/** 勾选行里已退赛的人数:给「批量恢复」按钮的可用性与文案做依据。 */
|
||
const selectedScratchedCount = computed(
|
||
() => selectedRows.value.filter((row) => row.entryStatus === 'scratched').length,
|
||
)
|
||
|
||
/** 勾选行里非正常参赛的人数(退赛 / dq / dns),决定「批量退赛」是否还有意义。 */
|
||
const selectedUnrankableCount = computed(
|
||
() => selectedRows.value.filter((row) => !rankableRow(row)).length,
|
||
)
|
||
|
||
/** 勾选行的报名项状态文案;徽标与确认框共用。 */
|
||
const ENTRY_STATUS_LABEL: Record<EntryStatus, string> = {
|
||
registered: '正常参赛',
|
||
scratched: '已退赛',
|
||
dq: '取消资格',
|
||
dns: '未出场',
|
||
}
|
||
|
||
function entryStatusTitle(status: EntryStatus): string {
|
||
switch (status) {
|
||
case 'scratched':
|
||
return '已退赛(弃权):0 分且不占名次,可用「批量恢复」撤回'
|
||
case 'dq':
|
||
return '已被判罚取消资格:0 分且不占名次'
|
||
case 'dns':
|
||
return '已标记未出场:0 分且不占名次'
|
||
}
|
||
return '正常参赛'
|
||
}
|
||
|
||
function batchRows(): EntryBatchRow[] {
|
||
return selectedRows.value.map((row) => ({
|
||
entryId: row.entryId,
|
||
name: rowLabel(row),
|
||
bib: row.bib,
|
||
team: row.teamShort || row.teamName,
|
||
categoryId: row.categoryId,
|
||
entryStatus: row.entryStatus,
|
||
}))
|
||
}
|
||
|
||
/** 批量操作的共同前置检查;不通过时给出具体原因。 */
|
||
function canBatch(): boolean {
|
||
if (data.isMock) {
|
||
toast.error('示例数据模式不能批量操作', '后端未就绪,改动不会写入真实赛事')
|
||
return false
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可批量操作', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return false
|
||
}
|
||
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
|
||
// 团体名次同样是「重算的输入」,也在这里挡住(dirty 已把它算进去)。
|
||
if (dirty.value) {
|
||
toast.error('有未保存的修改', '批量操作会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
/** 点批量按钮:先算清「动谁、跳过谁、重算几个组别」,再让操作员确认。 */
|
||
function requestBatchAction(action: EntryBatchAction): void {
|
||
if (!canBatch()) return
|
||
const rows = batchRows()
|
||
const sizeError = entryBatchSizeError(rows.length)
|
||
if (sizeError !== null) {
|
||
toast.error('批量操作未提交', sizeError)
|
||
return
|
||
}
|
||
const plan = planEntryBatch(action, rows)
|
||
if (plan.changed === 0) {
|
||
const first = plan.skipped[0]
|
||
toast.info(
|
||
'没有可处理的行',
|
||
first === undefined ? null : `${first.name}:${first.note}`,
|
||
)
|
||
return
|
||
}
|
||
batchPlan.value = plan
|
||
batchDialog.value = true
|
||
}
|
||
|
||
function cancelBatchAction(): void {
|
||
batchDialog.value = false
|
||
batchPlan.value = null
|
||
}
|
||
|
||
/**
|
||
* 批量操作成功提示的副标题。
|
||
*
|
||
* 「移出报名」是物理删除、界面上没有撤销键,所以这里多指一句路:要重新报名不必去翻基础数据页,
|
||
* 录入界面上就有「补报名」。
|
||
*/
|
||
function batchOutcomeDetail(result: EntryBatchResult, action: EntryBatchAction): string {
|
||
const outcome = entryBatchOutcome(result)
|
||
if (action !== 'remove') return outcome
|
||
return `${outcome};如需重新报名,点上方「补报名」从已有名单重新勾选`
|
||
}
|
||
|
||
async function confirmBatchAction(): Promise<void> {
|
||
const plan = batchPlan.value
|
||
if (plan === null || batchBusy.value) return
|
||
batchBusy.value = true
|
||
try {
|
||
const result = await submitEntryBatch({ action: plan.action, ids: plan.ids })
|
||
batchDialog.value = false
|
||
batchPlan.value = null
|
||
// 整表重载:状态、名次与积分都由服务端重算后的口径决定,不能只在本地的行上打补丁。
|
||
await reload()
|
||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||
clearSelection()
|
||
const failed = result.items.filter((item) => !item.ok)
|
||
if (failed.length > 0) {
|
||
// 部分失败必须逐条说清,不能只报一句「批量操作完成」。
|
||
toast.warning(
|
||
`批量操作完成(${failed.length} 条未处理)`,
|
||
failed.map((item) => `${item.id}:${item.error ?? '失败'}`).join(';'),
|
||
)
|
||
} else {
|
||
toast.success(BATCH_SUCCESS_TITLE[plan.action], batchOutcomeDetail(result, plan.action))
|
||
}
|
||
} catch (caught) {
|
||
toast.error('批量操作失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
batchBusy.value = false
|
||
}
|
||
}
|
||
|
||
const BATCH_SUCCESS_TITLE: Record<EntryBatchAction, string> = {
|
||
scratch: '已批量退赛,积分榜已重算',
|
||
restore: '已批量恢复,积分榜已重算',
|
||
remove: '已移出报名,积分榜已重算',
|
||
}
|
||
|
||
const BATCH_CONFIRM_TITLE: Record<EntryBatchAction, string> = {
|
||
scratch: '确认批量退赛?',
|
||
restore: '确认批量恢复?',
|
||
remove: '确认移出报名?此操作不可恢复',
|
||
}
|
||
|
||
const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
|
||
scratch: '确认退赛',
|
||
restore: '确认恢复',
|
||
remove: '确认移出',
|
||
}
|
||
|
||
// ── 单条修改 / 作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────
|
||
|
||
/** 待确认的行操作;非 null 时确认框打开。 */
|
||
const resultAction = ref<ResultActionTarget | null>(null)
|
||
const resultActionBusy = ref(false)
|
||
|
||
/** 确认框文案随动作变(修改 / 作废 / 退回草稿的后果不同)。 */
|
||
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
|
||
|
||
/**
|
||
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
|
||
*
|
||
* 一个按钮一次只带一个动作(同一行可能并排着「修改本条」与「作废本条」),
|
||
* 判定仍由纯函数给出:传进来的动作必须在该行成立,否则当没点。
|
||
*
|
||
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
|
||
* 否则刚填的名次会被服务端返回的口径覆盖掉。
|
||
*/
|
||
function requestResultAction(target: ResultActionTarget): void {
|
||
const current = rowActions.value.get(target.entryId)
|
||
if (current === undefined || !current.some((item) => item.action === target.action)) return
|
||
if (data.isMock) {
|
||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
|
||
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
|
||
toast.error(notice.title, notice.detail)
|
||
return
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return
|
||
}
|
||
if (dirty.value) {
|
||
toast.error('有未保存的修改', '修改 / 作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||
return
|
||
}
|
||
resultAction.value = target
|
||
}
|
||
|
||
/** 已发布行的格子点一下走哪个动作;草稿行 / 已作废行返回 null(照常直接编辑 / 保持禁用)。 */
|
||
function cellAction(row: EditableRow): ResultRowAction | null {
|
||
return resultCellAction(row)
|
||
}
|
||
|
||
/**
|
||
* 这一格是否保持原来的 disabled 锁定(收不到点击)。
|
||
*
|
||
* 已作废的行走这里:它的出路是按钮上的「重新录入」,格子不该装成点得动。
|
||
* 可点的已发布格子改用 readonly——disabled 的控件连 click 事件都不会派发,弹不出确认框。
|
||
*/
|
||
function cellDisabled(row: EditableRow): boolean {
|
||
return rowLocked(row) && cellAction(row) === null
|
||
}
|
||
|
||
/**
|
||
* 点已发布行的成绩格 / 状态格 = 点「修改本条」。
|
||
*
|
||
* 必须复用 requestResultAction 这一条路:确认框(含原因必填)、示例数据 / 只读 / 未保存修改
|
||
* 三道拦截、确认后整表重拉都在里面。格子这里绝不能自己调接口——否则就成了
|
||
* 「点一下成绩就悄悄从榜上退下来」。用户取消时也什么都回不去(见 cancelResultAction)。
|
||
*/
|
||
function requestCellAction(row: EditableRow): void {
|
||
const action = cellAction(row)
|
||
if (action === null) return
|
||
const target = resultActionTarget(row, action)
|
||
if (target !== null) requestResultAction(target)
|
||
}
|
||
|
||
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
|
||
async function confirmResultAction(reason: string): Promise<void> {
|
||
const target = resultAction.value
|
||
if (target === null || resultActionBusy.value) return
|
||
resultActionBusy.value = true
|
||
try {
|
||
if (target.action === 'void') {
|
||
await adminApi.voidResult(target.resultId, { reason })
|
||
} else {
|
||
// 「修改本条」与「重新录入」是同一个接口:两者都把这一条退回草稿,只是入口语境不同。
|
||
await adminApi.reopenResult(target.resultId, { reason })
|
||
}
|
||
resultAction.value = null
|
||
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
|
||
await reloadAfterSettlementChange()
|
||
// 退回草稿后把焦点送回那一格,操作员不必再拿鼠标找一遍就能接着改;
|
||
// 作废没有可改的格子(它的出路是「重新录入」),不聚焦。
|
||
if (target.action !== 'void') await focusResultCell(target.entryId)
|
||
const success = RESULT_ACTION_SUCCESS[target.action]
|
||
toast.success(success.title, success.detail)
|
||
} catch (caught) {
|
||
toast.error(`${RESULT_ROW_ACTION_WORD[target.action]}失败`, caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
resultActionBusy.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消确认框:什么都不做。
|
||
*
|
||
* 这里只能清掉待办的动作——退回草稿的接口一次都不能提前发出去,
|
||
* 否则「点一下格子又点了取消」就会把一条已发布成绩悄悄从榜上退下来。
|
||
*/
|
||
function cancelResultAction(): void {
|
||
if (resultActionBusy.value) return
|
||
resultAction.value = null
|
||
}
|
||
|
||
/**
|
||
* 把焦点落到某一行的成绩输入格(退回草稿成功后调用)。
|
||
*
|
||
* 必须等 nextTick:整表刚被 reload 换过一轮,DOM 还没更新完,这时候查不到新那一格的节点。
|
||
*/
|
||
async function focusResultCell(entryId: number): Promise<void> {
|
||
await nextTick()
|
||
const input = tableRef.value?.querySelector<HTMLInputElement>(`[data-result-cell="${entryId}"]`)
|
||
if (input === null || input === undefined) return
|
||
input.focus()
|
||
input.select()
|
||
}
|
||
|
||
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
|
||
function requestSplit(): void {
|
||
const plan = splitPlan.value
|
||
if (plan === null) {
|
||
toast.error('没有可编排的行', `当前列表里没有未分批的行,或每批人数不是正整数(1-${MAX_HEAT})`)
|
||
return
|
||
}
|
||
const endHeat = plan.startHeat + plan.batchCount - 1
|
||
splitMessage.value =
|
||
`将对未分批的 ${plan.rowCount} 行、每 ${plan.perHeat} 行一批进行编排,` +
|
||
`共 ${plan.batchCount} 批(第 ${plan.startHeat}${plan.batchCount > 1 ? `–${endHeat}` : ''} 批)。` +
|
||
`已分批的 ${visibleRows.value.length - plan.rowCount} 行保持不动。`
|
||
splitDialog.value = true
|
||
}
|
||
|
||
async function confirmSplit(): Promise<void> {
|
||
const plan = splitPlan.value
|
||
splitDialog.value = false
|
||
if (plan === null) return
|
||
await submitHeatAllocations(plan.allocations, '一键均分完成')
|
||
}
|
||
|
||
function autoSort(): void {
|
||
const direction = selectedEvent.value?.compare === 'asc' ? 1 : -1
|
||
allRows.value = [...allRows.value].sort((a, b) => {
|
||
const av = parseResultValue(a.resultText, unit.value)
|
||
const bv = parseResultValue(b.resultText, unit.value)
|
||
if (av === null && bv === null) return 0
|
||
if (av === null) return 1
|
||
if (bv === null) return -1
|
||
return direction * (av - bv)
|
||
})
|
||
toast.info('已按成绩重排(名次仍以比较值为准)')
|
||
}
|
||
|
||
/** Enter 在单元格之间移动;Tab 交给浏览器原生行为。 */
|
||
function onCellKeydown(event: KeyboardEvent, element: HTMLElement): void {
|
||
if (event.key !== 'Enter' || event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return
|
||
event.preventDefault()
|
||
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
|
||
const index = cells.indexOf(element)
|
||
const next = cells[index + 1]
|
||
if (next !== undefined) {
|
||
next.focus()
|
||
if (next instanceof HTMLInputElement) next.select()
|
||
}
|
||
}
|
||
|
||
function onPageKeydown(event: KeyboardEvent): void {
|
||
if (!event.ctrlKey && !event.altKey) return
|
||
if (event.key !== 'Enter') return
|
||
event.preventDefault()
|
||
if (saving.value || publishing.value || unpublishing.value) return
|
||
if (event.altKey) requestPublish()
|
||
else void saveDraft()
|
||
}
|
||
|
||
/** 关闭/刷新页面前拦住未保存的编辑,避免一整组成绩静默丢失。 */
|
||
function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||
if (!dirty.value) return
|
||
event.preventDefault()
|
||
event.returnValue = ''
|
||
}
|
||
|
||
/**
|
||
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
|
||
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
|
||
*/
|
||
function onPaste(event: ClipboardEvent, startEntryId: number): void {
|
||
const text = event.clipboardData?.getData('text/plain') ?? ''
|
||
if (text.trim() === '') return
|
||
// 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本),
|
||
// 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。
|
||
const start = visibleRows.value.find((row) => row.entryId === startEntryId)
|
||
if (start === undefined || rowLocked(start)) return
|
||
event.preventDefault()
|
||
|
||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
|
||
let cursor = visibleRows.value.findIndex((row) => row.entryId === startEntryId)
|
||
|
||
for (const line of lines) {
|
||
const columns = line.split('\t').map((column) => column.trim())
|
||
if (cursor < 0 || cursor >= visibleRows.value.length) break
|
||
let target = visibleRows.value[cursor]
|
||
if (target === undefined) break
|
||
|
||
if (columns.length >= 2) {
|
||
const key = columns[0] ?? ''
|
||
const matched = visibleRows.value.find(
|
||
(row) => row.teamName.includes(key) || (row.teamShort !== '' && key.includes(row.teamShort)),
|
||
)
|
||
if (matched !== undefined) target = matched
|
||
target.resultText = columns[columns.length - 1] ?? ''
|
||
} else {
|
||
target.resultText = columns[0] ?? ''
|
||
}
|
||
cursor = visibleRows.value.indexOf(target) + 1
|
||
}
|
||
toast.info(`已粘贴 ${lines.length} 行`)
|
||
}
|
||
|
||
function moveFocusBy(delta: number): void {
|
||
const cells = Array.from(tableRef.value?.querySelectorAll<HTMLElement>('[data-cell]') ?? [])
|
||
if (cells.length === 0) return
|
||
const active = document.activeElement
|
||
const index = active instanceof HTMLElement ? cells.indexOf(active) : -1
|
||
const next = cells[Math.min(Math.max(index + delta, 0), cells.length - 1)]
|
||
next?.focus()
|
||
}
|
||
|
||
/**
|
||
* 进入录入页时默认选中小项:URL 带 eventId 时优先,否则取第一个未开始(pending)的项目,
|
||
* 都没有就取列表第一个。
|
||
*
|
||
* 注意口径:这里**没有**「上次编辑的小项」这种记忆——前端从未持久化过录入位置,
|
||
* 所以换人换班后回到录入页看到的是兜底结论,不是他离开时的那个项目。
|
||
*/
|
||
async function pickInitialEvent(): Promise<void> {
|
||
if (selectedEventId.value !== null) return
|
||
const query = route.query.eventId
|
||
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
|
||
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
|
||
selectEvent(fromQuery)
|
||
return
|
||
}
|
||
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
|
||
if (fallback !== undefined) selectEvent(fallback.id)
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('beforeunload', onBeforeUnload)
|
||
void pickInitialEvent()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||
})
|
||
|
||
// 项目列表由布局层异步加载,加载完成后补一次默认选中。
|
||
watch(
|
||
() => data.loaded,
|
||
(loaded) => {
|
||
if (loaded) void pickInitialEvent()
|
||
},
|
||
)
|
||
|
||
// 换项目 / 换组别后结算状态跟着变;提示是辅助信息,读失败不影响录入。
|
||
watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { immediate: true })
|
||
</script>
|
||
|
||
<template>
|
||
<div class="entry-page" @keydown="onPageKeydown">
|
||
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
|
||
<template #actions>
|
||
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
|
||
<button
|
||
class="mx-btn mx-btn-secondary"
|
||
type="button"
|
||
:disabled="!canWrite || saving || allPublished || data.isMock"
|
||
:title="allPublished ? `${scopeWord}已全部发布,请先撤销发布` : ''"
|
||
@click="saveDraft()"
|
||
>
|
||
<MxIcon name="check" :size="15" /> 存草稿
|
||
</button>
|
||
<button
|
||
class="mx-btn mx-btn-primary"
|
||
type="button"
|
||
:disabled="!canWrite || publishing || data.isMock || pendingRows.length === 0"
|
||
:title="pendingRows.length === 0 ? '没有可发布的成绩:未录行会被自动跳过' : ''"
|
||
@click="requestPublish"
|
||
>
|
||
<MxIcon name="push" :size="15" /> 发布<span v-if="hasPublished && pendingRows.length > 0">本批</span>
|
||
</button>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<div v-if="data.isMock" class="mock-note">
|
||
<MxIcon name="warning" :size="15" />
|
||
<span>{{ data.mockLabel }},示例数据模式下「发布」已停用,成绩不会写入真实赛事。</span>
|
||
</div>
|
||
|
||
<div class="entry-grid">
|
||
<aside class="mx-card project-panel">
|
||
<div class="mx-card-head"><span class="mx-card-title">项目</span></div>
|
||
<div class="project-list">
|
||
<div v-for="group in eventGroups" :key="group.key" class="project-group">
|
||
<div class="mx-sidebar-section">{{ group.name }}</div>
|
||
<button
|
||
v-for="event in group.events"
|
||
:key="event.id"
|
||
class="project-item"
|
||
:class="{ active: event.id === selectedEventId }"
|
||
type="button"
|
||
@click="selectEvent(event.id)"
|
||
>
|
||
<span class="project-name">{{ event.name }}</span>
|
||
<span class="mx-badge" :class="event.status === 'published' ? 'mx-badge-success' : 'mx-badge-warning'">
|
||
{{ event.status === 'published' ? '已发布' : '待录入' }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div v-if="data.events.length === 0" class="mx-empty">
|
||
<div class="mx-empty-title">没有可录入的项目</div>
|
||
<div class="mx-empty-desc">请先在「基础数据」创建小项</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="entry-main">
|
||
<div v-if="selectedEvent === null" class="mx-card">
|
||
<div class="mx-card-body">
|
||
<div class="mx-empty">
|
||
<div class="mx-empty-icon"><MxIcon name="entry" :size="28" /></div>
|
||
<div class="mx-empty-title">请选择一个项目</div>
|
||
<div class="mx-empty-desc">左侧列表按大项分组,红色标记为待录入</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="entry-toolbar">
|
||
<div class="toolbar-left">
|
||
<div class="mx-tabs" v-if="categories.length > 1">
|
||
<button
|
||
v-for="category in categories"
|
||
:key="category.id"
|
||
class="mx-tab"
|
||
:class="{ active: category.id === selectedCategoryId }"
|
||
type="button"
|
||
@click="selectCategory(category.id)"
|
||
>
|
||
{{ category.name }}
|
||
</button>
|
||
</div>
|
||
<!-- 批次只做展示 / 录入分组:筛选只影响这张表,不改任何计分口径。 -->
|
||
<label class="heat-filter">
|
||
<span class="heat-filter-label">批次</span>
|
||
<select v-model="selectedHeat" class="mx-cell-input heat-filter-select">
|
||
<option v-for="option in heatChoices" :key="String(option.value)" :value="option.value">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<span class="entry-counts">
|
||
<span class="mx-badge mx-badge-success">已填 {{ stateCounts.filled }}</span>
|
||
<span class="mx-badge mx-badge-warning">未录 {{ stateCounts.missing }}</span>
|
||
<span v-if="stateCounts.error > 0" class="mx-badge mx-badge-error">有错 {{ stateCounts.error }}</span>
|
||
</span>
|
||
<span v-if="selectedEvent.resultType !== 'none'" class="unit-hint mono">单位:{{ unit || '—' }}</span>
|
||
<!--
|
||
补报名:取消过报名的人要重新报上,入口就在这里(当前小项 / 组别同一视野内),
|
||
不必再跑去「基础数据 → 报名导入」找。打开的是同一个 picker,预选当前小项与组别。
|
||
-->
|
||
<button
|
||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || data.isMock"
|
||
:title="
|
||
data.isMock
|
||
? '示例数据模式下不能补报名'
|
||
: '从已有名单勾选,为当前小项的当前组别补录报名项(复用「基础数据 → 报名导入」的勾选界面)'
|
||
"
|
||
@click="openSupplement"
|
||
>
|
||
<MxIcon name="entry" :size="14" /> 补报名
|
||
</button>
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="autoSort">
|
||
<MxIcon name="adjust" :size="14" /> 按成绩重排
|
||
</button>
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="moveFocusBy(1)">
|
||
<MxIcon name="keyboard" :size="14" /> 下一格
|
||
</button>
|
||
<button
|
||
v-if="hasPublished"
|
||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || data.isMock"
|
||
@click="unpublishDialog = true"
|
||
>
|
||
撤销发布
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
批次录入进度:贴在工具栏正下方、横跨整行,扫一眼就知道这一批还差几条。
|
||
分母是当前筛选范围的行数,判据与发布防呆同源(entryProgress → judgeEntryRow)。
|
||
-->
|
||
<div class="entry-progress" :class="{ 'is-done': entryBatchProgress.done }">
|
||
<span class="entry-progress-text">{{ entryBatchProgress.text }}</span>
|
||
<div
|
||
class="mx-progress mx-progress-lg entry-progress-track"
|
||
:class="{
|
||
'mx-progress-success': entryBatchProgress.done,
|
||
'mx-progress-warning': !entryBatchProgress.done && entryBatchProgress.error > 0,
|
||
}"
|
||
>
|
||
<div class="mx-progress-bar" :style="{ width: `${entryBatchProgress.percent}%` }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
延迟结算提示:低调地放在进度条下面,只说明「这一组还没结算、榜单不跟批次走」,
|
||
不挡录入。右侧「立即结算」是人工兜底(比如有人退赛但没标 scratched)。
|
||
-->
|
||
<div v-if="pendingSettlement !== null" class="entry-settlement">
|
||
<MxIcon name="warning" :size="14" />
|
||
<span class="entry-settlement-text">
|
||
延迟结算:本组 {{ pendingSettlement.published }}/{{ pendingSettlement.required }} 人已出成绩,
|
||
名次与榜单等该组全部录完后一起更新(本页预览的名次仍是实时的)。
|
||
</span>
|
||
<button
|
||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || settling"
|
||
@click="settleDialog = true"
|
||
>
|
||
立即结算
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="selectedRows.length > 0" class="heat-bar">
|
||
<span class="heat-bar-title"><MxIcon name="entry" :size="15" /> 已选 {{ selectedRows.length }} 行</span>
|
||
<label class="heat-field">
|
||
<span>批次</span>
|
||
<input
|
||
v-model="heatInput"
|
||
class="mx-cell-input heat-input mono"
|
||
type="number"
|
||
min="1"
|
||
:max="MAX_HEAT"
|
||
list="heat-candidates"
|
||
:placeholder="String(nextHeat)"
|
||
/>
|
||
<datalist id="heat-candidates">
|
||
<option v-for="heat in existingHeats" :key="heat" :value="heat">第 {{ heat }} 批</option>
|
||
</datalist>
|
||
</label>
|
||
<button
|
||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || heatBusy || heatNumber === null"
|
||
:title="heatNumber === null ? `批次号需要是 1-${MAX_HEAT} 的整数` : ''"
|
||
@click="applyHeatToSelected"
|
||
>
|
||
<MxIcon name="check" :size="14" /> 设为第 {{ heatNumber ?? '—' }} 批
|
||
</button>
|
||
<button
|
||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || heatBusy"
|
||
@click="clearHeatForSelected"
|
||
>
|
||
<MxIcon name="close" :size="14" /> 清除批次
|
||
</button>
|
||
<span class="heat-bar-split"></span>
|
||
<label class="heat-field">
|
||
<span>每批</span>
|
||
<input
|
||
v-model="heatSizeInput"
|
||
class="mx-cell-input heat-size-input mono"
|
||
type="number"
|
||
min="1"
|
||
:max="MAX_HEAT"
|
||
/>
|
||
<span>人</span>
|
||
</label>
|
||
<button
|
||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || heatBusy || splitPlan === null"
|
||
:title="splitPlan === null ? '当前列表里没有未分批的行,或每批人数不是正整数' : ''"
|
||
@click="requestSplit"
|
||
>
|
||
<MxIcon name="adjust" :size="14" /> 一键均分
|
||
</button>
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">取消选择</button>
|
||
</div>
|
||
|
||
<!--
|
||
报名项批量操作栏:作用范围就是上面勾选的行(= 当前批次筛选范围内的行)。
|
||
退赛可逆、移出不可逆,所以两者都过确认框,先把「动谁、重算几个组别」说清。
|
||
dq / dns 是别人的判定,服务端会跳过,这里也把按钮的可用性说白。
|
||
-->
|
||
<div v-if="selectedRows.length > 0" class="entry-batch-bar">
|
||
<span class="entry-batch-title">
|
||
<MxIcon name="entry" :size="15" /> 批量操作 {{ selectedRows.length }} 行
|
||
</span>
|
||
<span class="entry-batch-hint">
|
||
仅作用于当前筛选范围({{ scopeWord }});{{ selectedUnrankableCount }} 人已非正常参赛
|
||
</span>
|
||
<button
|
||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || batchBusy || selectedUnrankableCount === selectedRows.length"
|
||
:title="
|
||
selectedUnrankableCount === selectedRows.length
|
||
? '选中的行都已是退赛 / 取消资格 / 未出场,没有可退赛的人'
|
||
: '标记为退赛(0 分且不占名次),可随时批量恢复'
|
||
"
|
||
@click="requestBatchAction('scratch')"
|
||
>
|
||
<MxIcon name="close" :size="14" /> 批量退赛
|
||
</button>
|
||
<button
|
||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || batchBusy || selectedScratchedCount === 0"
|
||
:title="selectedScratchedCount === 0 ? '选中的行里没有已退赛的人' : `把 ${selectedScratchedCount} 人恢复为正常参赛`"
|
||
@click="requestBatchAction('restore')"
|
||
>
|
||
<MxIcon name="check" :size="14" /> 批量恢复<span v-if="selectedScratchedCount > 0">({{ selectedScratchedCount }})</span>
|
||
</button>
|
||
<span class="heat-bar-split"></span>
|
||
<button
|
||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || batchBusy"
|
||
title="从报名名单里物理删除,连同成绩与积分流水一起删除,不可恢复"
|
||
@click="requestBatchAction('remove')"
|
||
>
|
||
<MxIcon name="warning" :size="14" /> 批量移出报名
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="showAvgMode" class="avg-mode-bar">
|
||
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
||
<label
|
||
v-for="option in AVG_MODE_OPTIONS"
|
||
:key="option.value"
|
||
class="avg-mode-option"
|
||
:class="{ active: avgMode.mode === option.value }"
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="avg-mode"
|
||
:value="option.value"
|
||
:checked="avgMode.mode === option.value"
|
||
:disabled="!canWrite || avgMode.saving"
|
||
@change="pickAvgMode(option.value)"
|
||
/>
|
||
<span class="avg-mode-text">{{ option.label }}</span>
|
||
<span class="avg-mode-hint">{{ option.hint }}</span>
|
||
</label>
|
||
<span v-if="avgMode.saving" class="avg-mode-hint">保存中…</span>
|
||
<span class="avg-mode-note">团体成绩 = 该单位成员成绩的平均值</span>
|
||
</div>
|
||
|
||
<div v-if="hasPublished" class="published-note">
|
||
<MxIcon name="lock" :size="15" />
|
||
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
|
||
录完再点一次「发布本批」即可,名次与积分会按已发布成绩整体重算。
|
||
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
|
||
</div>
|
||
|
||
<div class="entry-body">
|
||
<div class="mx-card table-card">
|
||
<div ref="tableRef" class="mx-table-wrap">
|
||
<table class="mx-table mx-table-compact mx-table-entry">
|
||
<thead>
|
||
<tr>
|
||
<th class="center select-col">
|
||
<input
|
||
type="checkbox"
|
||
:checked="allVisibleSelected"
|
||
:disabled="visibleRows.length === 0"
|
||
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
|
||
@change="toggleAllVisible"
|
||
/>
|
||
</th>
|
||
<th class="center">录入</th>
|
||
<th>号码</th>
|
||
<th class="heat-col">批次</th>
|
||
<th>单位</th>
|
||
<th>运动员 / 队</th>
|
||
<th>成绩输入</th>
|
||
<th class="center">名次</th>
|
||
<th>状态</th>
|
||
<th class="center">积分(自动)</th>
|
||
<th>备注</th>
|
||
<th class="center action-col">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="row in visibleRows"
|
||
:key="row.entryId"
|
||
:class="{
|
||
'is-dirty': rowDirty(row),
|
||
'is-focus': focusedEntryId === row.entryId,
|
||
'row-error': rowJudgement(row)?.state === 'error',
|
||
'row-missing': rowJudgement(row)?.state === 'missing',
|
||
}"
|
||
>
|
||
<td class="center">
|
||
<input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" />
|
||
</td>
|
||
<td class="center">
|
||
<!--
|
||
已作废是独立状态:它仍是 published(所以输入框照样锁着),
|
||
但成绩不计入名次与积分,徽标必须先说清这件事,别只显示「已发布」。
|
||
-->
|
||
<span
|
||
v-if="row.resultState === 'published' && row.status === 'withdrawn'"
|
||
class="mx-badge mx-badge-error"
|
||
title="本条已作废:不计入名次与积分,可用「重新录入」退回草稿"
|
||
>已作废</span>
|
||
<span
|
||
v-else-if="row.resultState === 'published'"
|
||
class="mx-badge mx-badge-success"
|
||
title="已发布上屏"
|
||
>已发布</span>
|
||
<span
|
||
v-else-if="rowJudgement(row)?.state === 'error'"
|
||
class="mx-badge mx-badge-error"
|
||
title="填写有误,会阻止发布"
|
||
>有错</span>
|
||
<span
|
||
v-else-if="rowJudgement(row)?.state === 'missing'"
|
||
class="mx-badge mx-badge-warning"
|
||
title="还没录成绩,发布时会自动跳过"
|
||
>未录</span>
|
||
<span v-else class="mx-badge mx-badge-success" title="已填,可发布">已填</span>
|
||
</td>
|
||
<td class="mono">{{ row.bib ?? '—' }}</td>
|
||
<td class="heat-col">
|
||
<span :class="row.heat === null ? 'heat-none' : 'heat-tag'">{{ heatLabel(row.heat) }}</span>
|
||
</td>
|
||
<td>
|
||
<!--
|
||
单位格兼作「成员单位与份额」的入口:点一下打开联队弹窗(默认就是当前这一个
|
||
单位,什么都不改等于没动过)。普通报名的文案与改造前逐字一样,联队才多一行
|
||
成员名 + 份额比例——两个单位各占多少分,在表格上就要看得见。
|
||
-->
|
||
<span
|
||
v-if="!isJointTeam(row.teamShares)"
|
||
class="team-cell is-editable"
|
||
:title="`${row.teamName}|点击设置成员单位与份额(两个单位合并参赛时用)`"
|
||
@click="openShareEditor(row)"
|
||
>{{ row.teamShort || row.teamName }}</span>
|
||
<span
|
||
v-else
|
||
class="team-cell is-editable is-joint"
|
||
:title="`${jointTeamLabel(row.teamShares)}|点击修改成员单位与份额`"
|
||
@click="openShareEditor(row)"
|
||
>
|
||
<span class="mx-badge mx-badge-info">联队</span>
|
||
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
|
||
{{ shareText(member) }}
|
||
</span>
|
||
</span>
|
||
</td>
|
||
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
|
||
<td class="result-cell">
|
||
<!--
|
||
已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击,
|
||
「点不动」正是现场反馈的观感),改成 readonly + 点一下弹确认框:
|
||
确认后才退回草稿,取消则什么都不会发生。
|
||
-->
|
||
<input
|
||
v-model="row.resultText"
|
||
data-cell
|
||
:data-result-cell="row.entryId"
|
||
class="mx-cell-input mono result-input"
|
||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error', 'is-locked-cell': cellAction(row) !== null }"
|
||
:disabled="!canWrite || cellDisabled(row)"
|
||
:readonly="cellAction(row) !== null"
|
||
:title="resultTitle(row)"
|
||
:placeholder="resultPlaceholder"
|
||
@click="requestCellAction(row)"
|
||
@focus="onResultFocus(row, $event)"
|
||
@blur="onResultBlur"
|
||
@input="onResultInput"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
@paste="onPaste($event, row.entryId)"
|
||
/>
|
||
<!--
|
||
实时悬浮提示:绝对定位 + pointer-events:none,不占布局、不挡打字;
|
||
只在当前聚焦的这一格出现,失焦即收起。
|
||
-->
|
||
<div
|
||
v-if="focusedResultId === row.entryId && focusedHint !== null"
|
||
class="result-hint"
|
||
:class="[`is-${focusedHint?.level}`, hintPlacement === 'above' ? 'is-above' : 'is-below']"
|
||
>
|
||
{{ focusedHint?.message }}
|
||
</div>
|
||
</td>
|
||
<td class="center">
|
||
<input
|
||
v-if="manualRank"
|
||
:value="row.rank ?? ''"
|
||
data-cell
|
||
type="number"
|
||
min="1"
|
||
class="mx-cell-input rank-input"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="lockedHint(row)"
|
||
@input="row.rank = rankInputValue($event)"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
/>
|
||
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
|
||
</td>
|
||
<td class="status-cell">
|
||
<span
|
||
v-if="row.entryStatus !== 'registered'"
|
||
class="mx-badge entry-status-badge"
|
||
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
|
||
:title="entryStatusTitle(row.entryStatus)"
|
||
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
|
||
<!--
|
||
已发布(未作废)的行不渲染下拉:下拉在锁定态只能 disabled,而 disabled 控件
|
||
收不到点击。换成一个可点的只读按钮,点一下与「修改本条」同一条路
|
||
(确认后才退回草稿);已作废的行仍走下面那个 disabled 下拉,保持现状。
|
||
-->
|
||
<button
|
||
v-if="cellAction(row) !== null"
|
||
type="button"
|
||
data-cell
|
||
class="mx-cell-input status-select is-locked-cell"
|
||
:disabled="!canWrite"
|
||
:title="lockedHint(row, true)"
|
||
@click="requestCellAction(row)"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
>{{ STATUS_LABEL[row.status] }}</button>
|
||
<select
|
||
v-else
|
||
v-model="row.status"
|
||
data-cell
|
||
class="mx-cell-input status-select"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="lockedHint(row)"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
>
|
||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td class="center">
|
||
<span class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
|
||
{{ formatPoints(pointsOf(row)) }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<input
|
||
v-model="row.note"
|
||
data-cell
|
||
class="mx-cell-input"
|
||
:disabled="!canWrite || rowLocked(row)"
|
||
:title="lockedHint(row)"
|
||
placeholder="可留空"
|
||
@focus="focusedEntryId = row.entryId"
|
||
@blur="focusedEntryId = null"
|
||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||
/>
|
||
</td>
|
||
<!--
|
||
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
|
||
正常已发布的行并排两个按钮——「修改本条」(退回草稿改一改,改完重新发布)与
|
||
「作废本条」(这一条不算了);已作废的行只有一个「重新录入」。
|
||
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能改 / 能作废」而不是一片空白。
|
||
-->
|
||
<td class="center action-cell">
|
||
<button
|
||
v-for="target in rowActionsOf(row)"
|
||
:key="target.action"
|
||
class="mx-btn mx-btn-sm action-btn"
|
||
:class="target.action === 'void' ? 'mx-btn-danger' : 'mx-btn-secondary'"
|
||
type="button"
|
||
:disabled="!canWrite || resultActionBusy"
|
||
:title="RESULT_ROW_ACTION_TITLE[target.action]"
|
||
@click="requestResultAction(target)"
|
||
>
|
||
{{ RESULT_ROW_ACTION_LABEL[target.action] }}
|
||
</button>
|
||
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!loading && visibleRows.length === 0">
|
||
<td colspan="12">
|
||
<div class="mx-empty">
|
||
<div class="mx-empty-title">
|
||
{{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }}
|
||
</div>
|
||
<div class="mx-empty-desc">
|
||
{{
|
||
categoryRows.length === 0
|
||
? '点上方「补报名」从已有名单勾选,或到「基础数据 → 报名导入」上传报名表'
|
||
: '换一个批次筛选,或选「全部」'
|
||
}}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="mx-card preview-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">实时积分预览</span>
|
||
<span class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<p class="preview-hint">
|
||
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
||
</p>
|
||
<ul class="preview-list">
|
||
<li v-for="row in preview" :key="row.teamId" class="preview-item">
|
||
<span class="preview-name">{{ row.name }}</span>
|
||
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
|
||
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
|
||
</span>
|
||
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<!--
|
||
团体名次:按单位录入,只喂给「团体 + 手动名次」的通道(设计 17 §八第 3 条)。
|
||
与上面的成绩表是两套数据,所以单开一块,且不参与成绩表的行选择 / 批次筛选。
|
||
-->
|
||
<section v-if="showTeamRanks" class="mx-card team-ranks-card" :class="{ 'is-readonly': !canWrite }">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">团体名次</span>
|
||
<div class="team-ranks-actions">
|
||
<span v-if="teamRanksLoading" class="team-ranks-hint">读取中…</span>
|
||
<span v-if="teamRankDirty" class="mx-badge mx-badge-warning">未保存</span>
|
||
<button
|
||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||
type="button"
|
||
:disabled="!canWrite || teamRanksSaving || !teamRankDirty"
|
||
:title="teamRankDirty ? '保存本组团体名次并重算名次与积分' : '没有改动'"
|
||
@click="saveTeamRanks"
|
||
>
|
||
<MxIcon name="check" :size="14" /> {{ teamRanksSaving ? '保存中…' : '保存团体名次' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<!--
|
||
guest / 断线只读时整块置灰并说明原因:团体名次是写操作,
|
||
读得到、改不了,必须让访客知道为什么输不进去。
|
||
-->
|
||
<div v-if="!canWrite" class="team-ranks-readonly-note">
|
||
<MxIcon name="warning" :size="15" />
|
||
<span>{{ auth.readOnly ? '当前为访客只读视图,团体名次只能查看,无法录入。' : '服务端未连接,暂时不能录入团体名次。' }}</span>
|
||
</div>
|
||
<p class="team-ranks-desc">
|
||
按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
|
||
<strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
|
||
</p>
|
||
<ul class="team-rank-list">
|
||
<li v-for="row in teamRankRows" :key="row.teamId" class="team-rank-item">
|
||
<span class="team-rank-code mono">{{ row.code }}</span>
|
||
<span class="team-rank-name">{{ row.shortName || row.name }}</span>
|
||
<input
|
||
v-model="row.input"
|
||
class="mx-cell-input rank-input mono"
|
||
type="number"
|
||
min="1"
|
||
:max="teamRankRows.length"
|
||
:disabled="!canWrite"
|
||
:class="{ 'is-invalid': teamRankIssueTeams.has(row.teamId) }"
|
||
:placeholder="`1-${teamRankRows.length}`"
|
||
:title="teamRankIssueTeams.has(row.teamId) ? '名次重复或超出参赛单位数,请确认' : ''"
|
||
/>
|
||
</li>
|
||
<li v-if="teamRankRows.length === 0" class="team-rank-empty">
|
||
没有可录入的参赛单位(单位列表为空或都已停用)
|
||
</li>
|
||
</ul>
|
||
<ul v-if="teamRankIssues.length > 0" class="issue-list team-rank-issues">
|
||
<li v-for="(message, index) in teamRankIssues" :key="index" class="warn">
|
||
<MxIcon name="bell" :size="14" />
|
||
<span>{{ message }}(只提示,不阻止保存)</span>
|
||
</li>
|
||
</ul>
|
||
<div v-if="teamRankRows.length > 0" class="team-ranks-foot">
|
||
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div v-if="issues.length > 0" class="mx-card issues-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">防呆校验</span>
|
||
<span class="issue-counts">
|
||
<span v-if="errors.length > 0" class="mx-badge mx-badge-error">{{ errors.length }} 项阻止发布</span>
|
||
<span v-if="warnings.length > 0" class="mx-badge mx-badge-warning">{{ warnings.length }} 项提醒(不挡发布)</span>
|
||
</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<ul class="issue-list">
|
||
<li v-for="(issue, index) in listedIssues" :key="index" :class="issue.level">
|
||
<MxIcon :name="issue.level === 'error' ? 'warning' : 'bell'" :size="14" />
|
||
<span>{{ issue.message }}</span>
|
||
</li>
|
||
</ul>
|
||
<p v-if="hiddenIssueCount > 0" class="issue-more">还有 {{ hiddenIssueCount }} 条提醒未列出;未录的行会在发布时自动跳过。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="hotkey-hint">
|
||
<MxIcon name="keyboard" :size="14" />
|
||
<kbd>Tab</kbd>/<kbd>Enter</kbd> 移动 · <kbd>Ctrl</kbd>+<kbd>Enter</kbd> 存草稿 ·
|
||
<kbd>Alt</kbd>+<kbd>Enter</kbd> 发布 · 支持从 Excel 粘贴「单位 + 成绩」两列
|
||
</p>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
:open="publishDialog"
|
||
tone="warning"
|
||
title="确认发布该组成绩?"
|
||
:message="publishMessage"
|
||
:details="warnings.map((issue) => issue.message)"
|
||
confirm-text="确认发布"
|
||
:pending="publishing"
|
||
@confirm="confirmPublish"
|
||
@cancel="publishDialog = false"
|
||
/>
|
||
|
||
<!--
|
||
补报名:外壳照页面里既有的写法(RecordsPage 的新增纪录弹窗)自己套一层,
|
||
里面的勾选界面直接复用基础数据 → 报名导入页的 EntryPicker,预选当前小项与组别。
|
||
做成弹窗而不是第二个页面,是因为补报名就发生在录入过程中间,不该把人从这张表上带走。
|
||
-->
|
||
<div v-if="supplementOpen" class="mx-modal-overlay open" @click.self="supplementOpen = false">
|
||
<div class="mx-modal mx-modal-xl" role="dialog" aria-modal="true">
|
||
<div class="mx-modal-head">
|
||
<span class="mx-modal-title">补报名</span>
|
||
<button class="mx-modal-close" type="button" @click="supplementOpen = false">
|
||
<MxIcon name="close" :size="16" />
|
||
</button>
|
||
</div>
|
||
<div class="mx-modal-body">
|
||
<p class="supplement-hint">
|
||
给<strong>{{ selectedEvent?.name }}</strong> · <strong>{{ currentCategoryName }}</strong>
|
||
补录报名项:勾选的人 / 单位立即加入本组,下面的成绩表会跟着刷新。要补的是别的小项或组别,
|
||
在弹窗里改选即可。
|
||
</p>
|
||
<EntryPicker
|
||
:dialog="true"
|
||
:preset-event-id="selectedEventId"
|
||
:preset-category-id="selectedCategoryId"
|
||
@submitted="onSupplementSubmitted"
|
||
/>
|
||
</div>
|
||
<div class="mx-modal-foot">
|
||
<button class="mx-btn mx-btn-secondary" type="button" @click="supplementOpen = false">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!--
|
||
成员单位与份额(联队,devdocs/19):外壳照页面里既有的弹窗写法自己套一层。
|
||
默认摆出来的就是这条报名当前的份额——单单位报名打开后只有一行主单位、权重留空,
|
||
不点「添加」「保存」的话一个请求都不会发出去,常规报名流程不受影响。
|
||
-->
|
||
<div v-if="shareEditor !== null" class="mx-modal-overlay open" @click.self="closeShareEditor">
|
||
<div class="mx-modal" role="dialog" aria-modal="true">
|
||
<div class="mx-modal-head">
|
||
<span class="mx-modal-title">成员单位与份额</span>
|
||
<button class="mx-modal-close" type="button" @click="closeShareEditor">
|
||
<MxIcon name="close" :size="16" />
|
||
</button>
|
||
</div>
|
||
<div class="mx-modal-body">
|
||
<p class="share-hint">
|
||
一条报名可以挂<strong>多个单位</strong>(两个单位因人数不足合并参赛时用):这条成绩的积分按份额
|
||
拆给各成员单位,奖牌<strong>各记一枚</strong>;分数平均拆、接受小数(团体第 1 名 15 分 → 两个单位各 7.5 分)。
|
||
份额是<strong>相对权重</strong>:都留空就是均分(各 50%),填 2 与 1 就是 67% 与 33%。
|
||
</p>
|
||
<p class="bd-note">
|
||
正在修改:{{ shareEditor.label }};主单位「{{ shareEditor.primaryName }}」不能去掉。
|
||
</p>
|
||
<!-- 访客 / 断网只读:改不了就说明白原因,别让人对着灰按钮猜。 -->
|
||
<div v-if="!canWrite" class="share-readonly">
|
||
<MxIcon name="lock" :size="14" />
|
||
<span>{{ auth.readOnly ? '当前为访客只读视图,成员单位只能查看。' : '服务端未连接,暂时不能修改成员单位。' }}</span>
|
||
</div>
|
||
<ul class="share-list">
|
||
<li v-for="member in shareEditor.draft" :key="member.teamId" class="share-item">
|
||
<span class="share-code mono">{{ member.teamCode || '—' }}</span>
|
||
<span class="share-name">{{ member.teamName }}</span>
|
||
<span v-if="member.teamId === shareEditor.primaryTeamId" class="mx-badge mx-badge-neutral">主单位</span>
|
||
<input
|
||
class="mx-cell-input share-weight mono"
|
||
type="number"
|
||
min="0"
|
||
step="0.5"
|
||
placeholder="留空=1"
|
||
:value="member.weightText"
|
||
:disabled="!canWrite || shareEditor.saving"
|
||
title="份额是相对权重:留空或填 0 都按 1 算(均分)"
|
||
@input="editShareWeight(member.teamId, $event)"
|
||
/>
|
||
<button
|
||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||
type="button"
|
||
:disabled="member.teamId === shareEditor.primaryTeamId || shareEditor.saving"
|
||
:title="
|
||
member.teamId === shareEditor.primaryTeamId
|
||
? '主单位不能去掉:报名挂在它名下,换主单位要重新建报名'
|
||
: '把这个单位从这个联队里去掉'
|
||
"
|
||
@click="dropShareMember(member.teamId)"
|
||
>
|
||
移除
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<div class="share-add">
|
||
<select
|
||
v-model="pendingShareTeamId"
|
||
class="mx-cell-input bd-select"
|
||
:disabled="!canWrite || shareEditor.saving || shareCandidates.length === 0"
|
||
>
|
||
<option value="">添加成员单位…</option>
|
||
<option v-for="team in shareCandidates" :key="team.id" :value="String(team.id)">
|
||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||
</option>
|
||
</select>
|
||
<button
|
||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||
type="button"
|
||
:disabled="pendingShareTeamId === '' || shareEditor.saving"
|
||
@click="addShareMember"
|
||
>
|
||
添加
|
||
</button>
|
||
</div>
|
||
<p class="share-ratio">
|
||
当前比例:<strong>{{ shareRatioText }}</strong>
|
||
<span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span>
|
||
</p>
|
||
<ul v-if="shareIssues.length > 0" class="issue-list">
|
||
<li v-for="(message, index) in shareIssues" :key="index" class="error">
|
||
<MxIcon name="warning" :size="14" />
|
||
<span>{{ message }}</span>
|
||
</li>
|
||
</ul>
|
||
<div v-if="shareEditor.error !== null" class="mx-badge mx-badge-error share-error">
|
||
{{ shareEditor.error }}
|
||
</div>
|
||
<p class="bd-note">
|
||
保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的,
|
||
点「关闭」不会发出任何请求。
|
||
</p>
|
||
</div>
|
||
<div class="mx-modal-foot">
|
||
<button class="mx-btn mx-btn-secondary" type="button" @click="closeShareEditor">
|
||
{{ shareChanged ? '放弃修改' : '关闭' }}
|
||
</button>
|
||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor">
|
||
<MxIcon name="check" :size="14" />
|
||
{{ shareEditor.saving ? '保存中…' : '保存成员单位' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
:open="unpublishDialog"
|
||
tone="danger"
|
||
title="确认撤销发布?"
|
||
message="撤销发布将把该组已发布的成绩退回草稿,并作废已产生的积分流水,大屏会回退到发布前的状态。"
|
||
:details="[`影响单位:${affectedTeams} 个`, `积分回退:${formatPoints(maxDelta)} 分`]"
|
||
confirm-text="撤销发布"
|
||
:pending="unpublishing"
|
||
@confirm="confirmUnpublish"
|
||
@cancel="unpublishDialog = false"
|
||
/>
|
||
|
||
<!--
|
||
单条作废 / 重新录入:原因必填(服务端写审计),后果写在弹窗里。
|
||
与「撤销发布」分开:这是外科手术式改一条,不动同组其它已发布成绩。
|
||
-->
|
||
<ConfirmDialog
|
||
:open="resultAction !== null"
|
||
tone="danger"
|
||
:title="resultActionCopy.title"
|
||
:message="resultActionCopy.message"
|
||
:details="
|
||
resultAction === null
|
||
? []
|
||
: [`${resultAction.resultText === '' ? '(空成绩)' : resultAction.resultText}`, '同组其它人的名次与积分会一起重算并推送大屏']
|
||
"
|
||
:confirm-text="resultActionCopy.confirmText"
|
||
:reason-required="true"
|
||
:reason-label="resultActionCopy.reasonLabel"
|
||
:pending="resultActionBusy"
|
||
@confirm="confirmResultAction"
|
||
@cancel="cancelResultAction"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="settleDialog"
|
||
tone="warning"
|
||
title="立即结算该组积分?"
|
||
:message="`将按本组当前已发布的成绩重算各单位总分并刷新奖牌榜,未录完的人不计入。`"
|
||
:details="[
|
||
pendingSettlement === null
|
||
? '本组已无未结算内容'
|
||
: `本组已发布 ${pendingSettlement.published}/${pendingSettlement.required} 人`,
|
||
'结算后继续补录并发布,全组录完时还会再自动结算一次',
|
||
]"
|
||
confirm-text="立即结算"
|
||
:pending="settling"
|
||
@confirm="confirmSettle"
|
||
@cancel="settleDialog = false"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="splitDialog"
|
||
tone="warning"
|
||
title="确认一键均分?"
|
||
:message="splitMessage"
|
||
:details="[
|
||
'只编排未分批的行;已经分好批的行一个都不动',
|
||
`批次号接着当前最大号往后排,不会从第 1 批覆盖`,
|
||
]"
|
||
confirm-text="开始编排"
|
||
:pending="heatBusy"
|
||
@confirm="confirmSplit"
|
||
@cancel="splitDialog = false"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="discardDialog"
|
||
tone="warning"
|
||
:title="DISCARD_CONFIRM_TITLE"
|
||
:message="DISCARD_CONFIRM_MESSAGE"
|
||
:details="discardDetails"
|
||
confirm-text="丢弃并继续"
|
||
cancel-text="留在本页"
|
||
@confirm="confirmDiscard"
|
||
@cancel="cancelDiscard"
|
||
/>
|
||
|
||
<!-- 批量操作确认:移出报名不可恢复,用 danger;退赛 / 恢复可逆,用 warning / info。 -->
|
||
<ConfirmDialog
|
||
:open="batchDialog"
|
||
:tone="batchPlan?.action === 'remove' ? 'danger' : batchPlan?.action === 'restore' ? 'info' : 'warning'"
|
||
:title="BATCH_CONFIRM_TITLE[batchPlan?.action ?? 'scratch']"
|
||
:message="batchPlan?.message ?? ''"
|
||
:details="batchPlan?.details ?? []"
|
||
:confirm-text="BATCH_CONFIRM_TEXT[batchPlan?.action ?? 'scratch']"
|
||
:pending="batchBusy"
|
||
@confirm="confirmBatchAction"
|
||
@cancel="cancelBatchAction"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 补报名弹窗的说明行:与弹窗正文同宽,先点名「补的是哪一组」再让人勾选。 */
|
||
.supplement-hint {
|
||
margin-bottom: 14px;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: var(--mx-t2);
|
||
}
|
||
.mock-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
font-size: 13px;
|
||
background: rgba(251, 191, 36, 0.1);
|
||
color: var(--mx-amber);
|
||
}
|
||
.entry-grid {
|
||
display: grid;
|
||
grid-template-columns: 264px minmax(0, 1fr);
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
.project-panel {
|
||
position: sticky;
|
||
top: 0;
|
||
max-height: calc(100dvh - 140px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.project-list {
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
.project-group + .project-group {
|
||
margin-top: 6px;
|
||
}
|
||
.project-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 9px 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
cursor: pointer;
|
||
color: var(--mx-t2);
|
||
font-size: 13px;
|
||
text-align: left;
|
||
transition: all var(--mx-d1) var(--mx-ease);
|
||
}
|
||
.project-item:hover {
|
||
background: var(--mx-s-hover);
|
||
color: var(--mx-text);
|
||
}
|
||
.project-item.active {
|
||
background: var(--mx-primary-glow);
|
||
color: var(--mx-primary);
|
||
font-weight: 600;
|
||
}
|
||
.project-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.entry-main {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
.entry-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toolbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
/*
|
||
* 批次筛选下拉:紧挨组别页签,默认停在「全部」,不改变现有录入习惯。
|
||
* 只影响这张表的显示,不改任何计分口径。
|
||
*/
|
||
.heat-filter {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.heat-filter-select {
|
||
width: auto;
|
||
min-width: 104px;
|
||
}
|
||
/* 批量设置批次的临时操作条:只有勾了行才出现。 */
|
||
.heat-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px 12px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
border: 1px solid var(--mx-primary);
|
||
background: var(--mx-primary-glow);
|
||
}
|
||
.heat-bar-title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mx-primary);
|
||
}
|
||
.heat-field {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--mx-t2);
|
||
}
|
||
.heat-input {
|
||
width: 76px;
|
||
text-align: center;
|
||
}
|
||
.heat-size-input {
|
||
width: 64px;
|
||
text-align: center;
|
||
}
|
||
.heat-bar-split {
|
||
width: 1px;
|
||
height: 20px;
|
||
background: var(--mx-divider);
|
||
}
|
||
/*
|
||
* 报名项批量操作栏:与批次操作条同排出现,勾了行才显示。
|
||
* 用警示色边框把「这里有不可恢复动作」和批次操作区分开。
|
||
*/
|
||
.entry-batch-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px 12px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
border: 1px solid var(--mx-error);
|
||
background: var(--mx-error-container);
|
||
}
|
||
.entry-batch-title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mx-error);
|
||
}
|
||
.entry-batch-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
/* 状态列同时放报名项徽标(已退赛等)与成绩状态下拉,横排不换行。 */
|
||
.status-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.status-cell .status-select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.entry-status-badge {
|
||
flex: none;
|
||
white-space: nowrap;
|
||
}
|
||
/*
|
||
* 批次列只放「第 N 批 / 未分批」这类短文本,窄列即可:
|
||
* 自动布局下 nowrap + width:1% 会收缩到文字宽度,不会挤坏单位 / 运动员列
|
||
* (成绩列的 min-width 是另一套约束,两者互不干扰)。
|
||
*/
|
||
.heat-col {
|
||
white-space: nowrap;
|
||
width: 1%;
|
||
}
|
||
.select-col {
|
||
width: 1%;
|
||
}
|
||
.heat-tag {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--mx-primary);
|
||
}
|
||
.heat-none {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.unit-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.avg-mode-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px 16px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
border: 1px solid var(--mx-primary);
|
||
background: var(--mx-primary-glow);
|
||
}
|
||
.avg-mode-title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mx-primary);
|
||
}
|
||
.avg-mode-option {
|
||
display: inline-flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--mx-t2);
|
||
cursor: pointer;
|
||
}
|
||
.avg-mode-option.active .avg-mode-text {
|
||
color: var(--mx-primary);
|
||
font-weight: 600;
|
||
}
|
||
.avg-mode-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.avg-mode-note {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.published-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: rgba(52, 211, 153, 0.08);
|
||
color: var(--mx-green);
|
||
font-size: 13px;
|
||
}
|
||
.entry-body {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 300px;
|
||
gap: 14px;
|
||
align-items: start;
|
||
}
|
||
.table-card {
|
||
min-width: 0;
|
||
}
|
||
.table-card .mx-table-wrap {
|
||
max-height: calc(100dvh - 320px);
|
||
overflow: auto;
|
||
}
|
||
.team-cell {
|
||
font-weight: 600;
|
||
}
|
||
/* 单位格兼作「成员单位与份额」的入口:悬停给出手型与底色,点开的是同一个弹窗。
|
||
文案本身不动——单单位报名的这一格与改造前逐字一样。 */
|
||
.team-cell.is-editable {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
border-radius: var(--mx-r-sm);
|
||
cursor: pointer;
|
||
}
|
||
.team-cell.is-editable:hover {
|
||
background: var(--mx-primary-glow);
|
||
}
|
||
/* 联队:单位名 + 各自份额逐条列出(名字写全,比例看得见),不再压成一行。 */
|
||
.team-cell.is-joint {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
font-weight: 600;
|
||
}
|
||
.joint-member {
|
||
font-weight: 500;
|
||
font-size: 12px;
|
||
color: var(--mx-t2);
|
||
}
|
||
/* ── 成员单位与份额弹窗 ── */
|
||
.share-hint {
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: var(--mx-t2);
|
||
}
|
||
.share-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin: 10px 0;
|
||
}
|
||
.share-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: var(--mx-s3);
|
||
}
|
||
.share-code {
|
||
min-width: 44px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.share-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.share-weight {
|
||
width: 96px;
|
||
text-align: center;
|
||
}
|
||
.share-add {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.share-ratio {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
color: var(--mx-t2);
|
||
}
|
||
.share-readonly {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin: 0 0 8px;
|
||
font-size: 12px;
|
||
color: var(--mx-amber);
|
||
}
|
||
.share-error {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
padding: 10px 12px;
|
||
line-height: 1.5;
|
||
}
|
||
/*
|
||
* 行操作区:已发布的行要并排放「修改本条」+「作废本条」两个按钮,
|
||
* 列宽按两者之和留(4 字 ×12px + 内边距 + 单元格 padding),否则按钮会换行把这一行撑高一倍。
|
||
* 按钮内边距收窄(12 → 10px)是为了把列宽压住,不与上面的列宽估算脱节。
|
||
*/
|
||
.action-col,
|
||
.action-cell {
|
||
width: 180px;
|
||
white-space: nowrap;
|
||
}
|
||
.action-cell .action-btn {
|
||
padding: 0 10px;
|
||
}
|
||
.action-cell .action-btn + .action-btn {
|
||
margin-left: 6px;
|
||
}
|
||
.action-cell .muted {
|
||
color: var(--mx-t3);
|
||
}
|
||
/*
|
||
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
|
||
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
|
||
*/
|
||
.team-ranks-card.is-readonly {
|
||
opacity: 0.7;
|
||
}
|
||
.team-ranks-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.team-ranks-readonly-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
margin-bottom: 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
font-size: 12px;
|
||
background: rgba(251, 191, 36, 0.1);
|
||
color: var(--mx-amber);
|
||
}
|
||
.team-ranks-desc {
|
||
margin-bottom: 10px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: var(--mx-t3);
|
||
}
|
||
.team-rank-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 14px;
|
||
}
|
||
.team-rank-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: var(--mx-s2);
|
||
font-size: 12px;
|
||
color: var(--mx-t2);
|
||
}
|
||
.team-rank-code {
|
||
color: var(--mx-t3);
|
||
}
|
||
.team-rank-name {
|
||
min-width: 72px;
|
||
}
|
||
.team-rank-empty {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.team-rank-issues {
|
||
margin-top: 10px;
|
||
}
|
||
.team-ranks-foot {
|
||
margin-top: 10px;
|
||
}
|
||
.team-ranks-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.center {
|
||
text-align: center;
|
||
}
|
||
/*
|
||
* 成绩格必须放得下 `1:06.33`:表格是自动布局,输入框 width:100% 对列宽没有贡献,
|
||
* 这一列会被挤到连 4 位数字都看不见。
|
||
* 12px 等宽字体下实测 `1:06.33` 最宽约 50.6px(DejaVu Sans Mono),加 20px 左右内边距
|
||
* 与 2px 边框约需 73px;取 112px 留 ~1.5 倍余量,可容纳 12 个等宽字符。
|
||
* 输入框与单元格各写一份 min-width:前者保证输入本身够宽,后者保证列被撑开
|
||
* (自动布局下有些浏览器只认其中一边)。140 = 112 + 12/16px 左右内边距。
|
||
* 单位 / 运动员列是 nowrap,最小宽度天然等于文字宽度,不会被这一列压没。
|
||
*/
|
||
.result-input {
|
||
min-width: 112px;
|
||
}
|
||
.result-cell {
|
||
position: relative;
|
||
min-width: 140px;
|
||
}
|
||
/*
|
||
* 成绩格实时提示:绝对定位 + pointer-events:none,不占布局、不改变行高、不挡继续打字。
|
||
* 默认挂在输入框下方;贴近表格底部时(.is-above)改挂上方,避免被 .mx-table-wrap 的 overflow 裁掉。
|
||
* 颜色沿用令牌:不合法用 --mx-error,可疑用 --mx-amber,解析结果用 --mx-t3。
|
||
*/
|
||
.result-hint {
|
||
position: absolute;
|
||
left: 0;
|
||
z-index: 20;
|
||
width: max-content;
|
||
max-width: 280px;
|
||
padding: 4px 8px;
|
||
border-radius: var(--mx-r-xs);
|
||
border: 1px solid var(--mx-border);
|
||
background: var(--mx-s3);
|
||
box-shadow: var(--mx-sh2);
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
white-space: normal;
|
||
pointer-events: none;
|
||
}
|
||
.result-hint.is-below {
|
||
top: calc(100% + 4px);
|
||
}
|
||
.result-hint.is-above {
|
||
bottom: calc(100% + 4px);
|
||
}
|
||
.result-hint.is-ok {
|
||
color: var(--mx-t3);
|
||
}
|
||
.result-hint.is-warn {
|
||
color: var(--mx-amber);
|
||
border-color: var(--mx-amber);
|
||
}
|
||
.result-hint.is-error {
|
||
color: var(--mx-error);
|
||
border-color: var(--mx-error);
|
||
}
|
||
/*
|
||
* 示例文本要一眼看出不是已填内容:更淡(--mx-t3)+ 斜体。
|
||
* 只挂在录入表格范围内,不动全局 .mx-cell-input——基础数据等页面也在用它。
|
||
*/
|
||
.mx-table-entry .mx-cell-input::placeholder {
|
||
color: var(--mx-t3);
|
||
font-style: italic;
|
||
}
|
||
.rank-input {
|
||
width: 72px;
|
||
text-align: center;
|
||
}
|
||
.status-select {
|
||
width: 110px;
|
||
}
|
||
/*
|
||
* 已发布行的可点格子(成绩格 / 状态格):从「彻底禁用」改成「点一下退回草稿改」。
|
||
* 底色与草稿格(--mx-s3)不同 + 常态描边 + cursor: pointer,一眼看出这里点得动;
|
||
* 点下去只是弹与「修改本条」同一个确认框,不确认则什么都不会发生。
|
||
* 已作废行的格子 cellAction 为 null:拿不到这个类,也仍走 disabled(保持原来的禁用观感)。
|
||
*/
|
||
.is-locked-cell {
|
||
cursor: pointer;
|
||
text-align: left;
|
||
background: var(--mx-s2);
|
||
border-color: var(--mx-border);
|
||
}
|
||
.is-locked-cell:hover:not(:disabled) {
|
||
border-color: var(--mx-primary);
|
||
box-shadow: 0 0 0 3px var(--mx-primary-glow);
|
||
}
|
||
.is-locked-cell:disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
.rank-auto {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--mx-primary);
|
||
}
|
||
.points-cell {
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
color: var(--mx-green);
|
||
}
|
||
.points-cell.zero {
|
||
color: var(--mx-t3);
|
||
font-weight: 600;
|
||
}
|
||
.preview-card {
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
.preview-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
line-height: 1.6;
|
||
margin-bottom: 12px;
|
||
}
|
||
.preview-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.preview-item {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto auto;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
border-radius: var(--mx-r-xs);
|
||
background: var(--mx-s3);
|
||
font-size: 13px;
|
||
}
|
||
.preview-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.preview-delta {
|
||
font-weight: 700;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
min-width: 42px;
|
||
text-align: right;
|
||
}
|
||
.preview-delta.up {
|
||
color: var(--mx-green);
|
||
}
|
||
.preview-delta.down {
|
||
color: var(--mx-red);
|
||
}
|
||
.preview-total {
|
||
font-weight: 800;
|
||
min-width: 46px;
|
||
text-align: right;
|
||
}
|
||
.issues-card .mx-card-body {
|
||
padding-top: 12px;
|
||
}
|
||
.issue-counts {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
.issue-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.issue-list li {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.issue-list li.error {
|
||
color: var(--mx-red);
|
||
}
|
||
.issue-list li.warn {
|
||
color: var(--mx-amber);
|
||
}
|
||
.issue-more {
|
||
margin-top: 10px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
line-height: 1.6;
|
||
}
|
||
/*
|
||
* 三类行的左侧色条:未录(中性,可跳过)用灰色,写错(阻塞发布)用红色。
|
||
* 只做细边,不整行染色——录入时仍需看清成绩文字。
|
||
*/
|
||
.mx-table-entry tbody tr.row-missing td:first-child {
|
||
box-shadow: inset 3px 0 0 var(--mx-t3);
|
||
}
|
||
.mx-table-entry tbody tr.row-error td:first-child {
|
||
box-shadow: inset 3px 0 0 var(--mx-red);
|
||
}
|
||
.entry-counts {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
/*
|
||
* 批次录入进度:横跨整行、文字加粗,是这一屏最显眼的「还差几条」指示;
|
||
* 进度条本身用 MIUIx 的 mx-progress / mx-progress-success(录完变绿),不引新样式。
|
||
*/
|
||
.entry-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--mx-border);
|
||
border-radius: var(--mx-r-sm);
|
||
background: var(--mx-s3);
|
||
}
|
||
.entry-progress-text {
|
||
flex: none;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--mx-t2);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.entry-progress.is-done .entry-progress-text {
|
||
color: var(--mx-green);
|
||
}
|
||
/* 延迟结算提示:中性色 + 细边,属于「知道就好」的信息,不抢进度条与成绩格的注意力。 */
|
||
.entry-settlement {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
border: 1px dashed var(--mx-border);
|
||
border-radius: var(--mx-r-sm);
|
||
background: transparent;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.entry-settlement-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
line-height: 1.6;
|
||
}
|
||
.entry-progress-track {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
/* 姓名后的号码布:小号、弱化、等宽,补一次号码但不抢姓名的位置。 */
|
||
.bib-inline {
|
||
font-size: 11px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.hotkey-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
flex-wrap: wrap;
|
||
}
|
||
kbd {
|
||
padding: 1px 6px;
|
||
border: 1px solid var(--mx-border);
|
||
border-radius: 5px;
|
||
background: var(--mx-s3);
|
||
font-family: var(--mx-mono);
|
||
font-size: 11px;
|
||
}
|
||
@media (max-width: 1279px) {
|
||
.entry-body {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.preview-card {
|
||
position: static;
|
||
}
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.entry-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.project-panel {
|
||
position: static;
|
||
max-height: none;
|
||
}
|
||
}
|
||
</style>
|