用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
863 lines
29 KiB
Vue
863 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import {
|
||
ApiError,
|
||
formatPoints,
|
||
type EntryWithResult,
|
||
type Event,
|
||
type EventCategory,
|
||
type ResultStatus,
|
||
} from '@/shared'
|
||
import MxIcon from '../components/MxIcon.vue'
|
||
import PageHeader from '../components/PageHeader.vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import { adminApi } from '../api/client'
|
||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||
import { liveOnPublishOf } from '../components/system/systemApi'
|
||
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useEventStore } from '../stores/event'
|
||
import { useToastStore } from '../stores/toast'
|
||
|
||
/** 一条草稿成绩:展示与对比都只用这里面的字段。 */
|
||
interface DraftRow {
|
||
entryId: number
|
||
teamId: number
|
||
teamName: string
|
||
teamShort: string
|
||
athleteName: string
|
||
memberNames: string[] | null
|
||
resultText: string
|
||
rank: number | null
|
||
status: ResultStatus
|
||
isTie: boolean
|
||
points: number
|
||
}
|
||
|
||
/** 一个「小项 + 组别」的草稿组;发布以它为事务边界(07 第 3 节)。 */
|
||
interface DraftGroup {
|
||
key: string
|
||
event: Event
|
||
eventName: string
|
||
eventCode: string
|
||
sportName: string
|
||
category: EventCategory | null
|
||
categoryName: string
|
||
rows: DraftRow[]
|
||
deltas: TeamDelta[]
|
||
affectedTeams: number
|
||
maxDelta: number
|
||
}
|
||
|
||
interface TeamDelta {
|
||
teamId: number
|
||
name: string
|
||
delta: number
|
||
}
|
||
|
||
const data = useEventStore()
|
||
const auth = useAuthStore()
|
||
const toast = useToastStore()
|
||
|
||
const groups = ref<DraftGroup[]>([])
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const search = ref('')
|
||
const selectedKey = ref<string | null>(null)
|
||
const selectedEntryId = ref<number | null>(null)
|
||
const compareMode = ref<'group' | 'row'>('group')
|
||
const publishTarget = ref<DraftGroup | null>(null)
|
||
const publishing = ref(false)
|
||
const returnTarget = ref<DraftGroup | null>(null)
|
||
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
|
||
const returnedKeys = ref<string[]>([])
|
||
|
||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||
/**
|
||
* 延迟结算:发布只落成绩,榜单等该组全部录完才更新。
|
||
* 这里用的是快照里的设置;读不到(非 admin 且快照未到)时按默认「实时结算」处理。
|
||
*/
|
||
const deferredSettlement = computed(() => !liveOnPublishOf(data.settings))
|
||
/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */
|
||
const weight = computed(() => {
|
||
const w = data.settings?.standings.individualToTeamWeight ?? 1
|
||
return w > 0 ? w : 1
|
||
})
|
||
|
||
const filteredGroups = computed(() => {
|
||
const keyword = search.value.trim()
|
||
if (keyword === '') return groups.value
|
||
return groups.value.filter(
|
||
(group) =>
|
||
group.eventName.includes(keyword) ||
|
||
group.categoryName.includes(keyword) ||
|
||
group.rows.some((row) => row.teamName.includes(keyword) || row.athleteName.includes(keyword)),
|
||
)
|
||
})
|
||
|
||
const selectedGroup = computed<DraftGroup | null>(
|
||
() => groups.value.find((group) => group.key === selectedKey.value) ?? null,
|
||
)
|
||
|
||
const selectedRow = computed<DraftRow | null>(() => {
|
||
const group = selectedGroup.value
|
||
if (group === null || selectedEntryId.value === null) return null
|
||
return group.rows.find((row) => row.entryId === selectedEntryId.value) ?? null
|
||
})
|
||
|
||
/** 逐条对比:这一条自己的得分(个人项目按系数折算到单位)。 */
|
||
const rowImpact = computed<TeamDelta[]>(() => {
|
||
const row = selectedRow.value
|
||
if (row === null) return []
|
||
const individual = selectedGroup.value?.event.eventType === 'individual'
|
||
const delta = round(row.points * (individual ? weight.value : 1))
|
||
if (delta === 0) return []
|
||
return [{ teamId: row.teamId, name: row.teamShort || row.teamName, delta }]
|
||
})
|
||
|
||
const impactRows = computed<TeamDelta[]>(() => {
|
||
if (compareMode.value === 'row') return rowImpact.value
|
||
return selectedGroup.value?.deltas ?? []
|
||
})
|
||
|
||
const impactAffected = computed(() => impactRows.value.length)
|
||
const impactMax = computed(() => impactRows.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
|
||
|
||
const publishedCount = computed(() => groups.value.reduce((sum, group) => sum + group.rows.length, 0))
|
||
const returnSummary = computed(() =>
|
||
returnTarget.value === null
|
||
? ''
|
||
: `将退回「${returnTarget.value.eventName} · ${returnTarget.value.categoryName}」的 ${returnTarget.value.rows.length} 条草稿,录入员可修改后重新提交。`,
|
||
)
|
||
|
||
function round(value: number): number {
|
||
return Number(value.toFixed(2))
|
||
}
|
||
|
||
function teamLabel(teamId: number, fallback: string): string {
|
||
const team = data.teamById(teamId)
|
||
return team?.shortName ?? team?.name ?? fallback
|
||
}
|
||
|
||
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
|
||
function isFallback(error: unknown): boolean {
|
||
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
|
||
}
|
||
|
||
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 ''
|
||
}
|
||
|
||
function rowLabel(row: DraftRow, index: number): string {
|
||
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||
return `第 ${index + 1} 行 ${name}`
|
||
}
|
||
|
||
function needsRankOf(group: DraftGroup): boolean {
|
||
return group.event.resultMode === 'manual' || group.event.compare === 'none'
|
||
}
|
||
|
||
/** 单行判定与录入页共用同一套纯函数:空成绩=未录(不挡),写错/缺名次=有错(挡)。 */
|
||
function judgeRow(group: DraftGroup, row: DraftRow, index: number): EntryRowJudgement {
|
||
return judgeEntryRow({
|
||
label: rowLabel(row, index),
|
||
resultText: row.resultText,
|
||
unit: unitOf(group.event.resultType),
|
||
status: row.status,
|
||
rank: row.rank,
|
||
needsRank: needsRankOf(group),
|
||
})
|
||
}
|
||
|
||
/** 发布前防呆:与录入页共用同一套判定,只有「写错」才挡发布,空成绩只是本次跳过。 */
|
||
function publishBlockers(group: DraftGroup): string[] {
|
||
const blockers: string[] = []
|
||
for (const [index, row] of group.rows.entries()) {
|
||
const blocker = judgeRow(group, row, index).blocker
|
||
if (blocker !== null) blockers.push(blocker)
|
||
}
|
||
return blockers
|
||
}
|
||
|
||
/** 本次发布会跳过的「还没录」行数,确认框必须说清楚只会发布已填的部分。 */
|
||
function skippedCount(group: DraftGroup): number {
|
||
return group.rows.filter((row, index) => judgeRow(group, row, index).state === 'missing').length
|
||
}
|
||
|
||
/** 本次发布真正会带上屏的行数。 */
|
||
function publishableCount(group: DraftGroup): number {
|
||
return group.rows.length - skippedCount(group)
|
||
}
|
||
|
||
function sportNameOf(event: Event): string {
|
||
return data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'
|
||
}
|
||
|
||
function buildGroup(event: Event, category: EventCategory | null, entries: EntryWithResult[]): DraftGroup | null {
|
||
const scoped = entries.filter((entry) => category === null || entry.categoryId === category.id)
|
||
const drafts = scoped.filter((entry) => entry.result?.state === 'draft')
|
||
if (drafts.length === 0) return null
|
||
|
||
const scorableOf = (list: EntryWithResult[]): ScorableRow[] =>
|
||
list.map((entry) => ({
|
||
entryId: entry.id,
|
||
teamId: entry.teamId,
|
||
athleteId: entry.athleteId,
|
||
resultValue: entry.result?.resultValue ?? null,
|
||
rank: entry.result?.rank ?? null,
|
||
status: entry.result?.status ?? 'ok',
|
||
isTie: entry.result?.isTie ?? false,
|
||
}))
|
||
const context: ScoreContext = {
|
||
event,
|
||
category,
|
||
channels: event.channels ?? [],
|
||
profiles: data.profiles,
|
||
weight: weight.value,
|
||
}
|
||
// 全量(含已发布)口径 = 本次发布后服务端会算出的结果。
|
||
const score = computeEventScore(scorableOf(scoped), context)
|
||
// 只含已发布行的一次独立算分 = 该组此刻在榜上的结果。
|
||
// 分批发布时不能拿全量口径里已发布行的分去扣:新一批会挤掉老一批的名次,
|
||
// 扣的分必须来自「只有已发布行」的那次算分,差值才是本次发布的真实影响。
|
||
const publishedScore = computeEventScore(
|
||
scorableOf(scoped.filter((entry) => entry.result?.state === 'published')),
|
||
context,
|
||
)
|
||
|
||
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
|
||
// rowTeamPoints 已按通道 scope 折算好个人→单位系数,与服务端流水口径一致。
|
||
const published = new Map<number, number>()
|
||
for (const entry of scoped) {
|
||
if (entry.result?.state !== 'published') continue
|
||
published.set(
|
||
entry.teamId,
|
||
(published.get(entry.teamId) ?? 0) + round(publishedScore.rowTeamPoints.get(entry.id) ?? 0),
|
||
)
|
||
}
|
||
|
||
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
|
||
const deltas: TeamDelta[] = []
|
||
for (const teamId of teamIds) {
|
||
const delta = round((score.teamPoints.get(teamId) ?? 0) - (published.get(teamId) ?? 0))
|
||
if (delta === 0) continue
|
||
deltas.push({ teamId, name: teamLabel(teamId, `单位 ${teamId}`), delta })
|
||
}
|
||
deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||
|
||
const rows: DraftRow[] = drafts.map((entry) => ({
|
||
entryId: entry.id,
|
||
teamId: entry.teamId,
|
||
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
|
||
teamShort: entry.shortName ?? '',
|
||
athleteName: entry.athleteName ?? '',
|
||
memberNames: entry.memberNames,
|
||
resultText: entry.result?.resultText ?? '',
|
||
rank: entry.result?.rank ?? null,
|
||
status: entry.result?.status ?? 'ok',
|
||
isTie: entry.result?.isTie ?? false,
|
||
points: round(score.rowPoints.get(entry.id) ?? 0),
|
||
}))
|
||
|
||
return {
|
||
key: `${event.id}-${category?.id ?? 0}`,
|
||
event,
|
||
eventName: event.name,
|
||
eventCode: event.code,
|
||
sportName: sportNameOf(event),
|
||
category,
|
||
categoryName: category?.name ?? '全部组别',
|
||
rows,
|
||
deltas,
|
||
affectedTeams: deltas.length,
|
||
maxDelta: deltas.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0),
|
||
}
|
||
}
|
||
|
||
/** 找出有草稿的小项:优先用结果接口的 state 过滤,接口未就绪时退回逐项探测。 */
|
||
async function draftEventIds(): Promise<number[]> {
|
||
try {
|
||
const page = await adminApi.client.results.list({ state: 'draft', size: 500 })
|
||
return [...new Set(page.items.map((item) => item.eventId))]
|
||
} catch (caught) {
|
||
if (!isFallback(caught)) throw caught
|
||
const found: number[] = []
|
||
await Promise.all(
|
||
data.events.map(async (event) => {
|
||
// adminApi.entries 走统一降级逻辑:后端未就绪时读示例数据。
|
||
const entries = await adminApi.entries(event.id)
|
||
if (entries.some((entry) => entry.result?.state === 'draft')) found.push(event.id)
|
||
}),
|
||
)
|
||
return found
|
||
}
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
if (!data.loaded) return
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const eventIds = await draftEventIds()
|
||
const loaded = await Promise.all(
|
||
eventIds.map(async (eventId) => {
|
||
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
|
||
const categories = event.categories ?? []
|
||
const scoped: Array<EventCategory | null> = categories.length > 0 ? categories : [null]
|
||
return scoped
|
||
.map((category) => buildGroup(event, category, entries))
|
||
.filter((group): group is DraftGroup => group !== null)
|
||
}),
|
||
)
|
||
groups.value = loaded.flat().sort((a, b) => a.eventCode.localeCompare(b.eventCode))
|
||
if (selectedKey.value !== null && !groups.value.some((group) => group.key === selectedKey.value)) {
|
||
selectedKey.value = null
|
||
}
|
||
if (selectedKey.value === null && groups.value.length > 0) {
|
||
selectedKey.value = groups.value[0]?.key ?? null
|
||
}
|
||
} catch (caught) {
|
||
error.value = caught instanceof Error ? caught.message : '加载草稿失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function selectGroup(group: DraftGroup): void {
|
||
selectedKey.value = group.key
|
||
selectedEntryId.value = null
|
||
}
|
||
|
||
function isReturned(group: DraftGroup): boolean {
|
||
return returnedKeys.value.includes(group.key)
|
||
}
|
||
|
||
function requestPublish(group: DraftGroup): void {
|
||
if (data.isMock) {
|
||
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
|
||
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
|
||
return
|
||
}
|
||
if (!canWrite.value) {
|
||
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||
return
|
||
}
|
||
const blockers = publishBlockers(group)
|
||
if (blockers.length > 0) {
|
||
toast.error(`有 ${blockers.length} 处校验未通过`, blockers[0] ?? null)
|
||
return
|
||
}
|
||
if (publishableCount(group) === 0) {
|
||
toast.error('没有可发布的成绩', '这组全是未录成绩的行,补录至少一条后再发布')
|
||
return
|
||
}
|
||
publishTarget.value = group
|
||
}
|
||
|
||
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */
|
||
const publishMessage = computed(() => {
|
||
const group = publishTarget.value
|
||
if (group === null) return ''
|
||
const scope =
|
||
skippedCount(group) > 0
|
||
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
|
||
: `本次将发布该组全部 ${publishableCount(group)} 条。`
|
||
const tail = deferredSettlement.value
|
||
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
|
||
: '发布后立即上屏并重算积分榜。'
|
||
return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}`
|
||
})
|
||
|
||
async function confirmPublish(): Promise<void> {
|
||
const group = publishTarget.value
|
||
if (group === null) return
|
||
publishing.value = true
|
||
try {
|
||
await adminApi.publish({ eventId: group.event.id, categoryId: group.category?.id })
|
||
publishTarget.value = null
|
||
await load()
|
||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||
if (deferredSettlement.value) {
|
||
toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`)
|
||
} else {
|
||
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||
}
|
||
} catch (caught) {
|
||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||
} finally {
|
||
publishing.value = false
|
||
}
|
||
}
|
||
|
||
function confirmReturn(): void {
|
||
const group = returnTarget.value
|
||
if (group === null) return
|
||
if (!returnedKeys.value.includes(group.key)) {
|
||
returnedKeys.value = [...returnedKeys.value, group.key]
|
||
}
|
||
returnTarget.value = null
|
||
toast.warning('已标记退回', '当前接口未提供「退回」动作,本次仅在本地标记,不影响服务端数据')
|
||
}
|
||
|
||
function undoReturn(group: DraftGroup): void {
|
||
returnedKeys.value = returnedKeys.value.filter((key) => key !== group.key)
|
||
toast.info('已取消退回标记')
|
||
}
|
||
|
||
function statusLabel(status: ResultStatus): string {
|
||
if (status === 'ok') return '有效'
|
||
if (status === 'dns') return 'DNS'
|
||
if (status === 'dq') return 'DQ'
|
||
if (status === 'dnf') return 'DNF'
|
||
return '退赛'
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
})
|
||
|
||
watch(
|
||
() => data.loaded,
|
||
(loaded) => {
|
||
if (loaded) void load()
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="review-page">
|
||
<PageHeader title="成绩审核" subtitle="草稿按「小项 + 组别」分组,发布是整组事务;草稿态绝不上屏">
|
||
<template #actions>
|
||
<span class="mx-badge mx-badge-warning">{{ publishedCount }} 条草稿待审</span>
|
||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
|
||
<MxIcon name="refresh" :size="15" /> 刷新
|
||
</button>
|
||
</template>
|
||
</PageHeader>
|
||
|
||
<div v-if="data.isMock" class="mock-note">
|
||
<MxIcon name="warning" :size="15" />
|
||
<span>{{ data.mockLabel }},示例数据模式下「发布整组」已停用,草稿不会写入真实赛事。</span>
|
||
</div>
|
||
|
||
<div v-if="error !== null" class="error-banner">
|
||
<MxIcon name="warning" :size="15" />
|
||
<span>{{ error }}</span>
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="load">重试</button>
|
||
</div>
|
||
|
||
<div class="review-grid">
|
||
<aside class="mx-card group-panel">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">待审草稿</span>
|
||
<span class="mx-badge mx-badge-neutral">{{ filteredGroups.length }} 组</span>
|
||
</div>
|
||
<div class="mx-card-body group-body">
|
||
<div class="mx-input-wrap search">
|
||
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||
<input v-model="search" class="mx-input" placeholder="搜索小项 / 组别 / 单位" />
|
||
</div>
|
||
|
||
<div v-if="loading" class="mx-empty">
|
||
<div class="mx-spinner mx-spinner-sm"></div>
|
||
<div class="mx-empty-title">正在加载草稿…</div>
|
||
</div>
|
||
|
||
<div v-else-if="filteredGroups.length === 0" class="mx-empty">
|
||
<div class="mx-empty-icon"><MxIcon name="review" :size="26" /></div>
|
||
<div class="mx-empty-title">没有待审草稿</div>
|
||
<div class="mx-empty-desc">录入页保存的草稿会出现在这里</div>
|
||
</div>
|
||
|
||
<div v-else class="group-list">
|
||
<button
|
||
v-for="group in filteredGroups"
|
||
:key="group.key"
|
||
class="group-item"
|
||
:class="{ active: group.key === selectedKey }"
|
||
type="button"
|
||
@click="selectGroup(group)"
|
||
>
|
||
<span class="group-main">
|
||
<span class="group-name">{{ group.eventName }}</span>
|
||
<span class="group-meta">{{ group.sportName }} · {{ group.categoryName }}</span>
|
||
</span>
|
||
<span class="group-side">
|
||
<span class="mx-badge mx-badge-warning">{{ group.rows.length }} 条</span>
|
||
<span v-if="isReturned(group)" class="mx-badge mx-badge-neutral">已退回</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="review-main">
|
||
<div v-if="selectedGroup === null" class="mx-card">
|
||
<div class="mx-card-body">
|
||
<div class="mx-empty">
|
||
<div class="mx-empty-icon"><MxIcon name="review" :size="28" /></div>
|
||
<div class="mx-empty-title">选择左侧一组草稿</div>
|
||
<div class="mx-empty-desc">对比发布影响后再决定发布或退回</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">
|
||
{{ selectedGroup.eventName }}
|
||
<small class="head-sub">{{ selectedGroup.sportName }} · {{ selectedGroup.categoryName }}</small>
|
||
</span>
|
||
<div class="head-actions">
|
||
<span v-if="isReturned(selectedGroup)" class="mx-badge mx-badge-neutral">已退回待修改</span>
|
||
<button
|
||
v-if="isReturned(selectedGroup)"
|
||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||
type="button"
|
||
@click="undoReturn(selectedGroup)"
|
||
>
|
||
取消退回
|
||
</button>
|
||
<button
|
||
class="mx-btn mx-btn-secondary"
|
||
type="button"
|
||
:disabled="!canWrite"
|
||
@click="returnTarget = selectedGroup"
|
||
>
|
||
<MxIcon name="logout" :size="14" /> 退回
|
||
</button>
|
||
<button
|
||
class="mx-btn mx-btn-primary"
|
||
type="button"
|
||
:disabled="!canWrite || publishing || data.isMock"
|
||
@click="requestPublish(selectedGroup)"
|
||
>
|
||
<MxIcon name="push" :size="14" /> 发布整组
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mx-card-body">
|
||
<div class="compare-bar">
|
||
<div class="mx-tabs mx-tabs-pill">
|
||
<button
|
||
class="mx-tab"
|
||
:class="{ active: compareMode === 'group' }"
|
||
type="button"
|
||
@click="compareMode = 'group'"
|
||
>
|
||
整组对比
|
||
</button>
|
||
<button
|
||
class="mx-tab"
|
||
:class="{ active: compareMode === 'row' }"
|
||
type="button"
|
||
:disabled="selectedRow === null"
|
||
@click="compareMode = 'row'"
|
||
>
|
||
逐条对比
|
||
</button>
|
||
</div>
|
||
<p class="impact-line">
|
||
本次将影响 <strong>{{ impactAffected }}</strong> 个单位,最大分差
|
||
<strong class="mono">{{ formatPoints(impactMax) }}</strong> 分
|
||
</p>
|
||
</div>
|
||
|
||
<div class="mx-table-wrap">
|
||
<table class="mx-table mx-table-compact">
|
||
<thead>
|
||
<tr>
|
||
<th class="center">录入</th>
|
||
<th>单位</th>
|
||
<th>运动员 / 队</th>
|
||
<th>成绩</th>
|
||
<th class="center">名次</th>
|
||
<th class="center">状态</th>
|
||
<th class="right">积分</th>
|
||
<th class="right">对比</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="(row, index) in selectedGroup.rows"
|
||
:key="row.entryId"
|
||
:class="{ selected: row.entryId === selectedEntryId }"
|
||
@click="selectedEntryId = row.entryId"
|
||
>
|
||
<td class="center">
|
||
<span
|
||
class="mx-badge"
|
||
:class="{
|
||
'mx-badge-error': judgeRow(selectedGroup, row, index).state === 'error',
|
||
'mx-badge-warning': judgeRow(selectedGroup, row, index).state === 'missing',
|
||
'mx-badge-success': judgeRow(selectedGroup, row, index).state === 'filled',
|
||
}"
|
||
>
|
||
{{
|
||
judgeRow(selectedGroup, row, index).state === 'error'
|
||
? '有错'
|
||
: judgeRow(selectedGroup, row, index).state === 'missing'
|
||
? '未录'
|
||
: '已填'
|
||
}}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||
</td>
|
||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||
<td class="mono">{{ row.resultText || '—' }}</td>
|
||
<td class="center mono">{{ row.rank ?? '—' }}</td>
|
||
<td class="center">{{ statusLabel(row.status) }}</td>
|
||
<td class="right mono points">{{ formatPoints(row.points) }}</td>
|
||
<td class="right">
|
||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click.stop="selectedEntryId = row.entryId; compareMode = 'row'">
|
||
看该条
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">
|
||
发布影响({{ compareMode === 'row' ? '逐条' : '整组' }})
|
||
</span>
|
||
<span class="mx-badge mx-badge-info">{{ impactAffected }} 个单位</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<ul v-if="impactRows.length > 0" class="impact-list">
|
||
<li v-for="item in impactRows" :key="item.teamId" class="impact-item">
|
||
<span class="impact-name">{{ item.name }}</span>
|
||
<span class="impact-delta mono" :class="item.delta > 0 ? 'up' : 'down'">
|
||
{{ item.delta > 0 ? '+' : '' }}{{ formatPoints(item.delta) }}
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="impact-empty">
|
||
{{ compareMode === 'row' ? '选中一行查看它单独的得分影响' : '本次发布不会改变任何单位的分数' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
:open="publishTarget !== null"
|
||
tone="warning"
|
||
title="确认发布该组成绩?"
|
||
:message="publishMessage"
|
||
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
||
confirm-text="确认发布"
|
||
:pending="publishing"
|
||
@confirm="confirmPublish"
|
||
@cancel="publishTarget = null"
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
:open="returnTarget !== null"
|
||
tone="danger"
|
||
title="确认退回这组草稿?"
|
||
:message="returnSummary"
|
||
:details="['服务端接口清单里没有「退回」动作,本次只在管理端做本地标记', '成绩数据不变,录入员修改后重新保存草稿即可']"
|
||
confirm-text="标记退回"
|
||
@confirm="confirmReturn"
|
||
@cancel="returnTarget = null"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.review-grid {
|
||
display: grid;
|
||
grid-template-columns: 300px minmax(0, 1fr);
|
||
gap: 16px;
|
||
align-items: start;
|
||
margin-top: 16px;
|
||
}
|
||
.group-panel {
|
||
position: sticky;
|
||
top: 0;
|
||
max-height: calc(100dvh - 140px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.group-body {
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.search {
|
||
width: 100%;
|
||
}
|
||
.group-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.group-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
width: 100%;
|
||
padding: 10px;
|
||
border-radius: var(--mx-r-sm);
|
||
text-align: left;
|
||
cursor: pointer;
|
||
color: var(--mx-t2);
|
||
transition: all var(--mx-d1) var(--mx-ease);
|
||
}
|
||
.group-item:hover {
|
||
background: var(--mx-s-hover);
|
||
color: var(--mx-text);
|
||
}
|
||
.group-item.active {
|
||
background: var(--mx-primary-glow);
|
||
color: var(--mx-primary);
|
||
}
|
||
.group-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.group-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.group-meta {
|
||
font-size: 11px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.group-side {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
align-items: flex-end;
|
||
flex-shrink: 0;
|
||
}
|
||
.review-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
min-width: 0;
|
||
}
|
||
.head-sub {
|
||
margin-left: 8px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--mx-t3);
|
||
}
|
||
.head-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.compare-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
.impact-line {
|
||
font-size: 13px;
|
||
color: var(--mx-t2);
|
||
}
|
||
.impact-line strong {
|
||
color: var(--mx-primary);
|
||
font-size: 15px;
|
||
}
|
||
.team-cell {
|
||
font-weight: 600;
|
||
}
|
||
.center {
|
||
text-align: center;
|
||
}
|
||
.right {
|
||
text-align: right;
|
||
}
|
||
.points {
|
||
color: var(--mx-green);
|
||
font-weight: 700;
|
||
}
|
||
tr.selected {
|
||
background: var(--mx-primary-glow);
|
||
}
|
||
.impact-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.impact-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
border-radius: var(--mx-r-xs);
|
||
background: var(--mx-s3);
|
||
font-size: 13px;
|
||
}
|
||
.impact-delta {
|
||
font-weight: 800;
|
||
}
|
||
.impact-delta.up {
|
||
color: var(--mx-green);
|
||
}
|
||
.impact-delta.down {
|
||
color: var(--mx-red);
|
||
}
|
||
.impact-empty {
|
||
font-size: 13px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.error-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: rgba(248, 113, 113, 0.08);
|
||
color: var(--mx-red);
|
||
font-size: 13px;
|
||
margin-top: 14px;
|
||
}
|
||
.mock-note {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--mx-r-sm);
|
||
background: rgba(251, 191, 36, 0.1);
|
||
color: var(--mx-amber);
|
||
font-size: 13px;
|
||
margin-top: 14px;
|
||
}
|
||
@media (max-width: 1279px) {
|
||
.review-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.group-panel {
|
||
position: static;
|
||
max-height: none;
|
||
}
|
||
}
|
||
</style>
|