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,649 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ApiError,
|
||||
formatDateTime,
|
||||
formatPoints,
|
||||
type Event,
|
||||
type RecordEntry,
|
||||
type RecordLevel,
|
||||
type RulePack,
|
||||
} 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 { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
/** 疑似破纪录:由「已发布成绩 vs 当前纪录」实时推导,接口不返回该列表。 */
|
||||
interface SuspectedBreak {
|
||||
key: string
|
||||
eventId: number
|
||||
eventName: string
|
||||
eventCode: string
|
||||
categoryName: string
|
||||
entryId: number
|
||||
teamName: string
|
||||
athleteName: string
|
||||
resultText: string
|
||||
resultValue: number
|
||||
level: RecordLevel
|
||||
recordText: string
|
||||
bonusPoints: number
|
||||
}
|
||||
|
||||
interface RecordGroup {
|
||||
eventId: number
|
||||
eventName: string
|
||||
eventCode: string
|
||||
categoryLabel: string
|
||||
records: RecordEntry[]
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<RecordLevel, string> = {
|
||||
meet: '赛会',
|
||||
city: '市级',
|
||||
province: '省级',
|
||||
national: '全国',
|
||||
}
|
||||
|
||||
const LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
|
||||
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const records = ref<RecordEntry[]>([])
|
||||
const recordsLoading = ref(false)
|
||||
const recordsError = ref<string | null>(null)
|
||||
const categoryLabels = ref<Record<number, string>>({})
|
||||
const suspected = ref<SuspectedBreak[]>([])
|
||||
const suspectedLoading = ref(false)
|
||||
const suspectedError = ref<string | null>(null)
|
||||
const rulePack = ref<RulePack | null>(null)
|
||||
const ruleError = ref<string | null>(null)
|
||||
const rulesLoading = ref(false)
|
||||
const search = ref('')
|
||||
const levelFilter = ref<RecordLevel | 'all'>('all')
|
||||
const approveTarget = ref<SuspectedBreak | null>(null)
|
||||
const approving = ref(false)
|
||||
const rejectTarget = ref<SuspectedBreak | null>(null)
|
||||
/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */
|
||||
const rejectedKeys = ref<string[]>([])
|
||||
const approvedKeys = ref<string[]>([])
|
||||
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false)
|
||||
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档'))
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
const keyword = search.value.trim()
|
||||
return records.value.filter((record) => {
|
||||
if (levelFilter.value !== 'all' && record.level !== levelFilter.value) return false
|
||||
if (keyword === '') return true
|
||||
const event = data.eventById(record.eventId)
|
||||
const label = categoryLabels.value[record.eventId] ?? ''
|
||||
return (
|
||||
(event?.name ?? '').includes(keyword) ||
|
||||
(event?.code ?? '').includes(keyword) ||
|
||||
label.includes(keyword) ||
|
||||
(record.holderName ?? '').includes(keyword)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const recordGroups = computed<RecordGroup[]>(() => {
|
||||
const map = new Map<number, RecordEntry[]>()
|
||||
for (const record of filteredRecords.value) {
|
||||
const list = map.get(record.eventId)
|
||||
if (list === undefined) map.set(record.eventId, [record])
|
||||
else list.push(record)
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([eventId, list]) => {
|
||||
const event = data.eventById(eventId)
|
||||
return {
|
||||
eventId,
|
||||
eventName: event?.name ?? `项目 ${eventId}`,
|
||||
eventCode: event?.code ?? '',
|
||||
categoryLabel: categoryLabels.value[eventId] ?? '未绑定组别',
|
||||
records: [...list].sort((a, b) => levelRank(a.level) - levelRank(b.level)),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.eventName.localeCompare(b.eventName))
|
||||
})
|
||||
|
||||
const pendingBreaks = computed(() =>
|
||||
suspected.value.filter(
|
||||
(item) => !rejectedKeys.value.includes(item.key) && !approvedKeys.value.includes(item.key),
|
||||
),
|
||||
)
|
||||
|
||||
function levelRank(level: RecordLevel): number {
|
||||
const index = LEVEL_ORDER.indexOf(level)
|
||||
return index < 0 ? 0 : index
|
||||
}
|
||||
|
||||
function isFallback(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
|
||||
)
|
||||
}
|
||||
|
||||
function teamLabel(teamId: number): string {
|
||||
const team = data.teamById(teamId)
|
||||
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
|
||||
}
|
||||
|
||||
/** 只有「已发布 + 有效成绩」才可能破纪录;草稿永远不参与判断。 */
|
||||
function beats(compare: Event['compare'], value: number, recordValue: number): boolean {
|
||||
if (compare === 'asc') return value < recordValue
|
||||
if (compare === 'desc') return value > recordValue
|
||||
return false
|
||||
}
|
||||
|
||||
async function loadRules(): Promise<void> {
|
||||
rulesLoading.value = true
|
||||
try {
|
||||
rulePack.value = await adminApi.client.rules.get()
|
||||
ruleError.value = null
|
||||
} catch (caught) {
|
||||
rulePack.value = null
|
||||
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
|
||||
} finally {
|
||||
rulesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
|
||||
const labels: Record<number, string> = {}
|
||||
await Promise.all(
|
||||
eventIds.map(async (eventId) => {
|
||||
const cached = data.eventById(eventId)?.categories
|
||||
if (cached !== undefined && cached.length > 0) {
|
||||
labels[eventId] = cached.map((category) => category.name).join('、')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detail = await adminApi.eventDetail(eventId)
|
||||
labels[eventId] = (detail.categories ?? []).map((category) => category.name).join('、')
|
||||
} catch {
|
||||
labels[eventId] = ''
|
||||
}
|
||||
}),
|
||||
)
|
||||
categoryLabels.value = labels
|
||||
}
|
||||
|
||||
async function loadRecords(): Promise<void> {
|
||||
recordsLoading.value = true
|
||||
recordsError.value = null
|
||||
try {
|
||||
records.value = await adminApi.records()
|
||||
const eventIds = [...new Set(records.value.map((record) => record.eventId))]
|
||||
await loadCategoryLabels(eventIds)
|
||||
} catch (caught) {
|
||||
recordsError.value = caught instanceof Error ? caught.message : '加载纪录库失败'
|
||||
} finally {
|
||||
recordsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSuspected(): Promise<void> {
|
||||
if (!data.loaded) return
|
||||
suspectedLoading.value = true
|
||||
suspectedError.value = null
|
||||
try {
|
||||
const candidates = data.events.filter(
|
||||
(event) => event.recordEligible && records.value.some((record) => record.eventId === event.id),
|
||||
)
|
||||
const found = await Promise.all(
|
||||
candidates.map(async (event) => {
|
||||
const entries = await adminApi.entries(event.id)
|
||||
const eventRecords = records.value.filter((record) => record.eventId === event.id)
|
||||
const list: SuspectedBreak[] = []
|
||||
for (const entry of entries) {
|
||||
const result = entry.result
|
||||
if (result === null || result.state !== 'published' || result.status !== 'ok') continue
|
||||
const value = result.resultValue
|
||||
if (value === null) continue
|
||||
let best: RecordEntry | null = null
|
||||
for (const record of eventRecords) {
|
||||
if (!beats(event.compare, value, record.resultValue)) continue
|
||||
if (best === null || levelRank(record.level) > levelRank(best.level)) best = record
|
||||
}
|
||||
if (best === null) continue
|
||||
list.push({
|
||||
key: `${event.id}-${entry.id}-${best.level}`,
|
||||
eventId: event.id,
|
||||
eventName: event.name,
|
||||
eventCode: event.code,
|
||||
categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—',
|
||||
entryId: entry.id,
|
||||
teamName: teamLabel(entry.teamId),
|
||||
athleteName: entry.athleteName ?? entry.memberNames?.join('、') ?? '—',
|
||||
resultText: result.resultText,
|
||||
resultValue: value,
|
||||
level: best.level,
|
||||
recordText: best.resultText,
|
||||
bonusPoints: bonusFor(best.level),
|
||||
})
|
||||
}
|
||||
return list
|
||||
}),
|
||||
)
|
||||
suspected.value = found.flat().sort((a, b) => levelRank(b.level) - levelRank(a.level))
|
||||
} catch (caught) {
|
||||
suspectedError.value = caught instanceof Error ? caught.message : '推导疑似破纪录失败'
|
||||
} finally {
|
||||
suspectedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function bonusFor(level: RecordLevel): number {
|
||||
const definition = rulePack.value?.recordBonus.levels.find((item) => item.code === level)
|
||||
return definition?.points ?? 0
|
||||
}
|
||||
|
||||
function requestApprove(item: SuspectedBreak): void {
|
||||
if (!recordBonusEnabled.value) {
|
||||
toast.error('本期破纪录加分已关闭', '规则包 recordBonus.enabled = false,确认后不会生成加分流水')
|
||||
return
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
approveTarget.value = item
|
||||
}
|
||||
|
||||
async function confirmApprove(): Promise<void> {
|
||||
const item = approveTarget.value
|
||||
if (item === null) return
|
||||
approving.value = true
|
||||
try {
|
||||
const created = await adminApi.client.records.createBreak({
|
||||
eventId: item.eventId,
|
||||
entryId: item.entryId,
|
||||
level: item.level,
|
||||
resultValue: item.resultValue,
|
||||
resultText: item.resultText,
|
||||
})
|
||||
await adminApi.client.records.approveBreak(created.id)
|
||||
approvedKeys.value = [...approvedKeys.value, item.key]
|
||||
approveTarget.value = null
|
||||
toast.success('已确认破纪录', `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`)
|
||||
await Promise.all([loadRecords(), data.refreshStandings()])
|
||||
} catch (caught) {
|
||||
if (isFallback(caught)) {
|
||||
// 后端未就绪:只做本地标记,绝不假装写成功。
|
||||
approvedKeys.value = [...approvedKeys.value, item.key]
|
||||
approveTarget.value = null
|
||||
toast.warning('已本地登记(示例数据)', '服务端未就绪,加分流水未真正生成')
|
||||
} else {
|
||||
toast.error('确认失败', caught instanceof Error ? caught.message : null)
|
||||
}
|
||||
} finally {
|
||||
approving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmReject(): void {
|
||||
const item = rejectTarget.value
|
||||
if (item === null) return
|
||||
rejectedKeys.value = [...rejectedKeys.value, item.key]
|
||||
rejectTarget.value = null
|
||||
toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现')
|
||||
}
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
await Promise.all([loadRecords(), loadRules()])
|
||||
await loadSuspected()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshAll()
|
||||
})
|
||||
|
||||
// 项目列表由布局层异步加载,加载完成后才能推导疑似破纪录。
|
||||
watch(
|
||||
() => data.loaded,
|
||||
(loaded) => {
|
||||
if (loaded) void loadSuspected()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="records-page">
|
||||
<PageHeader title="破纪录" subtitle="纪录库 + 疑似破纪录确认;本期加分功能默认关闭">
|
||||
<template #actions>
|
||||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="recordsLoading || suspectedLoading" @click="refreshAll">
|
||||
<MxIcon name="refresh" :size="15" /> 刷新
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mx-card notice-card">
|
||||
<div class="mx-card-body notice-body">
|
||||
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
|
||||
<div>
|
||||
<div class="notice-title">
|
||||
{{ recordBonusEnabled ? '破纪录加分已启用' : '本期破纪录加分已关闭' }}
|
||||
<span class="mx-badge" :class="recordBonusEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||
recordBonus.enabled = {{ recordBonusEnabled ? 'true' : 'false' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="notice-desc">
|
||||
<template v-if="recordBonusEnabled">
|
||||
确认破纪录会生成加分流水并更新纪录库;加分档位 {{ bonusMode }},与成绩积分相互独立(不随权重翻倍)。
|
||||
</template>
|
||||
<template v-else>
|
||||
规则包 `recordBonus.enabled = false`(02 第 3 节)。疑似破纪录仍会列出,但「确认加分」不会生成加分流水;
|
||||
以后开一个开关即可启用,档位与分值已预置。
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="ruleError !== null" class="notice-error">
|
||||
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mx-card mx-section">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">疑似破纪录</span>
|
||||
<span class="mx-badge mx-badge-neutral">{{ pendingBreaks.length }} 条待确认</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div v-if="suspectedLoading" class="mx-empty">
|
||||
<div class="mx-spinner mx-spinner-sm"></div>
|
||||
<div class="mx-empty-title">正在比对已发布成绩…</div>
|
||||
</div>
|
||||
<div v-else-if="suspectedError !== null" class="error-banner">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ suspectedError }}</span>
|
||||
</div>
|
||||
<div v-else-if="pendingBreaks.length === 0" class="mx-empty">
|
||||
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
|
||||
<div class="mx-empty-title">暂无疑似破纪录</div>
|
||||
<div class="mx-empty-desc">已发布成绩优于当前纪录时会自动出现在这里</div>
|
||||
</div>
|
||||
<div v-else class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>小项</th>
|
||||
<th>组别</th>
|
||||
<th>单位 / 运动员</th>
|
||||
<th>成绩</th>
|
||||
<th>原纪录</th>
|
||||
<th>档位</th>
|
||||
<th class="right">加分</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in pendingBreaks" :key="item.key">
|
||||
<td>
|
||||
<span class="event-name">{{ item.eventName }}</span>
|
||||
<small class="mono event-code">{{ item.eventCode }}</small>
|
||||
</td>
|
||||
<td>{{ item.categoryName }}</td>
|
||||
<td>
|
||||
<span class="team-name">{{ item.teamName }}</span>
|
||||
<small class="athlete">{{ item.athleteName }}</small>
|
||||
</td>
|
||||
<td class="mono highlight">{{ item.resultText }}</td>
|
||||
<td class="mono">{{ item.recordText }}</td>
|
||||
<td><span class="mx-tag mx-tag-primary">{{ LEVEL_LABELS[item.level] }}</span></td>
|
||||
<td class="right mono points">+{{ formatPoints(item.bonusPoints) }}</td>
|
||||
<td class="right actions">
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!recordBonusEnabled || !canWrite || approving"
|
||||
@click="requestApprove(item)"
|
||||
>
|
||||
确认加分
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="rejectTarget = item">拒绝</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!recordBonusEnabled && pendingBreaks.length > 0" class="hint">
|
||||
「确认加分」在 recordBonus 关闭时不可用,避免生成无规则的积分流水。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card mx-section">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">纪录库</span>
|
||||
<div class="head-tools">
|
||||
<div class="mx-tabs">
|
||||
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
|
||||
<button
|
||||
v-for="level in LEVEL_ORDER"
|
||||
:key="level"
|
||||
class="mx-tab"
|
||||
:class="{ active: levelFilter === level }"
|
||||
type="button"
|
||||
@click="levelFilter = level"
|
||||
>
|
||||
{{ LEVEL_LABELS[level] }}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div v-if="recordsLoading" class="mx-empty">
|
||||
<div class="mx-spinner mx-spinner-sm"></div>
|
||||
<div class="mx-empty-title">正在加载纪录库…</div>
|
||||
</div>
|
||||
<div v-else-if="recordsError !== null" class="error-banner">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ recordsError }}</span>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="loadRecords">重试</button>
|
||||
</div>
|
||||
<div v-else-if="recordGroups.length === 0" class="mx-empty">
|
||||
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
|
||||
<div class="mx-empty-title">没有匹配的纪录</div>
|
||||
<div class="mx-empty-desc">按小项 + 组别维护纪录库</div>
|
||||
</div>
|
||||
<div v-else class="record-groups">
|
||||
<div v-for="group in recordGroups" :key="group.eventId" class="record-group">
|
||||
<div class="record-group-head">
|
||||
<span class="event-name">{{ group.eventName }}</span>
|
||||
<small class="mono event-code">{{ group.eventCode }}</small>
|
||||
<span class="mx-tag">{{ group.categoryLabel }}</span>
|
||||
</div>
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>档位</th>
|
||||
<th>成绩</th>
|
||||
<th>保持者</th>
|
||||
<th>单位</th>
|
||||
<th>达成时间</th>
|
||||
<th>来源</th>
|
||||
<th class="center">当前</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in group.records" :key="record.id">
|
||||
<td><span class="mx-tag" :class="record.isCurrent ? 'mx-tag-primary' : ''">{{ LEVEL_LABELS[record.level] }}</span></td>
|
||||
<td class="mono highlight">{{ record.resultText }}</td>
|
||||
<td>{{ record.holderName ?? '—' }}</td>
|
||||
<td>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td>
|
||||
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
|
||||
<td>{{ record.source === 'current' ? '本届' : '历史' }}</td>
|
||||
<td class="center">
|
||||
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
|
||||
<span v-else class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="approveTarget !== null"
|
||||
tone="warning"
|
||||
title="确认破纪录并加分?"
|
||||
:message="
|
||||
approveTarget === null
|
||||
? ''
|
||||
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
|
||||
"
|
||||
:details="[
|
||||
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
|
||||
`加分模式:${bonusMode};破纪录加分与成绩积分相互独立,不随权重翻倍`,
|
||||
]"
|
||||
confirm-text="确认并加分"
|
||||
:pending="approving"
|
||||
@confirm="confirmApprove"
|
||||
@cancel="approveTarget = null"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="rejectTarget !== null"
|
||||
tone="danger"
|
||||
title="拒绝这条疑似破纪录?"
|
||||
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。"
|
||||
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
|
||||
confirm-text="确认拒绝"
|
||||
@confirm="confirmReject"
|
||||
@cancel="rejectTarget = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notice-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.notice-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.notice-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--mx-amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notice-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.notice-desc {
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.notice-error {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search {
|
||||
width: 220px;
|
||||
}
|
||||
.event-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.event-code {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
.athlete {
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.highlight {
|
||||
font-weight: 700;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.points {
|
||||
color: var(--mx-green);
|
||||
font-weight: 800;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.muted {
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.record-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.record-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user