feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
1 parent
f3813171d3
commit
72462792ec
39 files changed
+10001
-28
No files matched your search
@@ -0,0 +1,729 @@
|
||||
<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 { 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)
|
||||
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 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
|
||||
}
|
||||
|
||||
function isFallback(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
const individual = event.eventType === 'individual'
|
||||
|
||||
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
|
||||
const published = new Map<number, number>()
|
||||
for (const entry of scoped) {
|
||||
if (entry.result?.state !== 'published') continue
|
||||
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1)
|
||||
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
|
||||
}
|
||||
|
||||
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 (!canWrite.value) {
|
||||
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
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 data.refreshStandings()
|
||||
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="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"
|
||||
@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;
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
.review-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.group-panel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user