## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
operations/ 放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
interceptions/ 拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。
## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。
修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。
测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
788 lines
26 KiB
Vue
788 lines
26 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 { resultCellIssue } 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 会话的快照会把折算系数清零(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 publishBlockers(group: DraftGroup): string[] {
|
||
const unit = unitOf(group.event.resultType)
|
||
const manual = group.event.resultMode === 'manual' || group.event.compare === 'none'
|
||
const blockers: string[] = []
|
||
for (const [index, row] of group.rows.entries()) {
|
||
if (row.status !== 'ok') continue
|
||
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||
const label = `第 ${index + 1} 行 ${name}`
|
||
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
|
||
const emptyAllowed = row.resultText.trim() === '' && manual && row.rank !== null
|
||
const issue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit)
|
||
if (issue !== null) blockers.push(issue.message)
|
||
if (manual && row.rank === null) blockers.push(`${label}:缺少名次`)
|
||
}
|
||
return blockers
|
||
}
|
||
|
||
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 scorable: ScorableRow[] = scoped.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(scorable, 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(score.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
|
||
}
|
||
publishTarget.value = group
|
||
}
|
||
|
||
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()])
|
||
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>单位</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 in selectedGroup.rows"
|
||
:key="row.entryId"
|
||
:class="{ selected: row.entryId === selectedEntryId }"
|
||
@click="selectedEntryId = row.entryId"
|
||
>
|
||
<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="
|
||
publishTarget === null
|
||
? ''
|
||
: `本次将影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||
"
|
||
: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>
|