Files
Scoreboard/web/src/admin/pages/ReviewPage.vue
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 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。
2026-09-11 17:14:02 +08:00

863 lines
29 KiB
Vue
Raw Blame History

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