Files
Scoreboard/web/src/admin/pages/ReviewPage.vue
T
AskaEth e5e0944d26 feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复
## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
2026-09-11 01:59:58 +08:00

788 lines
26 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 { 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>