feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
+2
-60
@@ -1,65 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const health = ref<string>('检测中…')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/health')
|
||||
const body = (await res.json()) as { ok: boolean; version: string }
|
||||
health.value = body.ok ? `服务正常 · ${body.version}` : '服务异常'
|
||||
} catch {
|
||||
health.value = '无法连接服务端'
|
||||
}
|
||||
})
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="panel">
|
||||
<header>
|
||||
<h1>管理面板</h1>
|
||||
<span class="status">{{ health }}</span>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<h2>骨架已就绪</h2>
|
||||
<p>MIUIx 风格与业务页面将在 M4 实现。</p>
|
||||
</section>
|
||||
</main>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.status {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, createApiClient } from '@/shared/api'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
CurrentUser,
|
||||
DisplayCommand,
|
||||
EntryWithResult,
|
||||
Event,
|
||||
EventSettings,
|
||||
HealthInfo,
|
||||
Page,
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
PublishPayload,
|
||||
RecordEntry,
|
||||
Result,
|
||||
SaveDraftPayload,
|
||||
ScoringProfile,
|
||||
Sport,
|
||||
StandingRow,
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
} from '@/shared'
|
||||
import { mockDb } from './mock'
|
||||
|
||||
/**
|
||||
* 管理端数据入口。
|
||||
*
|
||||
* 后端未就绪(连接失败 / 超时 / 响应不是约定结构)时自动降级到内存示例数据,
|
||||
* 并把 `mockMode` 置为 true,界面据此显示「示例数据」标记。
|
||||
* 业务错误(鉴权失败、校验失败、冲突)不降级——那些是真实服务端的回答,必须原样呈现。
|
||||
*/
|
||||
|
||||
const FALLBACK_CODES = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
|
||||
|
||||
export const mockMode = ref(false)
|
||||
export const mockReason = ref<string | null>(null)
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void): void {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
const client = createApiClient({
|
||||
onUnauthorized: () => unauthorizedHandler?.(),
|
||||
})
|
||||
|
||||
function isFallback(error: unknown): boolean {
|
||||
// 404 表示该接口在当前服务端尚未实现(或整个后端没起来),同样按「未就绪」降级。
|
||||
return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
|
||||
}
|
||||
|
||||
async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
|
||||
try {
|
||||
const value = await real()
|
||||
if (mockMode.value) {
|
||||
mockMode.value = false
|
||||
mockReason.value = null
|
||||
}
|
||||
return value
|
||||
} catch (error) {
|
||||
if (!isFallback(error)) throw error
|
||||
mockMode.value = true
|
||||
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
|
||||
return await mock()
|
||||
}
|
||||
}
|
||||
|
||||
export interface Bootstrap {
|
||||
settings: EventSettings
|
||||
teams: Team[]
|
||||
sports: Sport[]
|
||||
events: Event[]
|
||||
profiles: ScoringProfile[]
|
||||
standings: StandingRow[]
|
||||
athleteStandings: AthleteStanding[]
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<Bootstrap> {
|
||||
return call(
|
||||
async () => {
|
||||
const [settings, teams, sports, events, profiles, standings, athleteStandings] =
|
||||
await Promise.all([
|
||||
client.settings.get(),
|
||||
client.teams.list(),
|
||||
client.sports.list(),
|
||||
client.events.list(),
|
||||
client.scoringProfiles.list(),
|
||||
client.standings.teams(),
|
||||
client.standings.athletes(),
|
||||
])
|
||||
return { settings, teams, sports, events, profiles, standings, athleteStandings }
|
||||
},
|
||||
() => mockDb.bootstrap(),
|
||||
)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
client,
|
||||
bootstrap,
|
||||
|
||||
eventDetail: (id: number): Promise<Event> =>
|
||||
call(() => client.events.get(id), () => mockDb.eventDetail(id)),
|
||||
|
||||
entries: (eventId: number): Promise<EntryWithResult[]> =>
|
||||
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
|
||||
|
||||
saveDraft: (payload: SaveDraftPayload): Promise<Result[]> =>
|
||||
call(() => client.results.saveDraft(payload), () => mockDb.saveDraft(payload)),
|
||||
|
||||
publish: (payload: PublishPayload): Promise<Result[]> =>
|
||||
call(() => client.results.publish(payload), () => mockDb.publish(payload)),
|
||||
|
||||
unpublish: (payload: PublishPayload): Promise<Result[]> =>
|
||||
call(() => client.results.unpublish(payload), () => mockDb.unpublish(payload)),
|
||||
|
||||
standings: (): Promise<StandingRow[]> =>
|
||||
call(() => client.standings.teams(), () => mockDb.standings()),
|
||||
|
||||
athleteStandings: (): Promise<AthleteStanding[]> =>
|
||||
call(() => client.standings.athletes(), () => mockDb.athleteStandings()),
|
||||
|
||||
breakdown: (teamId: number): Promise<TeamBreakdown> =>
|
||||
call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)),
|
||||
|
||||
records: (): Promise<RecordEntry[]> =>
|
||||
call(() => client.records.list(), () => mockDb.records()),
|
||||
|
||||
adjustments: (query: PageQuery = {}): Promise<Page<PointAdjustment>> =>
|
||||
call(() => client.adjustments.list(query), () => mockDb.adjustments()),
|
||||
|
||||
auditLogs: (query: PageQuery = {}): Promise<Page<AuditLog>> =>
|
||||
call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
|
||||
|
||||
displayCommand: (command: DisplayCommand): Promise<void> =>
|
||||
call(() => client.display.command(command), () => undefined),
|
||||
|
||||
login: (username: string, password: string): Promise<CurrentUser> =>
|
||||
call(
|
||||
() => client.auth.login(username, password),
|
||||
() => mockDb.login(username),
|
||||
),
|
||||
|
||||
logout: (): Promise<void> => call(() => client.auth.logout(), () => undefined),
|
||||
|
||||
me: (): Promise<CurrentUser> =>
|
||||
call(
|
||||
() => client.auth.me(),
|
||||
() => {
|
||||
// 示例数据模式下没有真实会话,统一按「未登录」处理。
|
||||
throw new ApiError('AUTH_REQUIRED', '示例数据模式下无会话', 0)
|
||||
},
|
||||
),
|
||||
|
||||
health: (): Promise<HealthInfo> =>
|
||||
call(
|
||||
() => client.health(),
|
||||
() => mockDb.health(),
|
||||
),
|
||||
|
||||
exportUrl: (kind: 'standings' | 'breakdown' | 'results'): string | null => {
|
||||
if (mockMode.value) return null
|
||||
if (kind === 'standings') return client.exports.standingsUrl()
|
||||
if (kind === 'breakdown') return client.exports.breakdownUrl({})
|
||||
return client.exports.resultsUrl()
|
||||
},
|
||||
}
|
||||
|
||||
export type AdminApi = typeof adminApi
|
||||
export { ApiError }
|
||||
@@ -0,0 +1,731 @@
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
CurrentUser,
|
||||
DraftResultInput,
|
||||
Entry,
|
||||
EntryWithResult,
|
||||
Event,
|
||||
EventCategory,
|
||||
EventSettings,
|
||||
HealthInfo,
|
||||
Page,
|
||||
PointAdjustment,
|
||||
PublishPayload,
|
||||
RecordEntry,
|
||||
Result,
|
||||
ResultStatus,
|
||||
ResultType,
|
||||
SaveDraftPayload,
|
||||
ScoreChannel,
|
||||
ScoringProfile,
|
||||
Sport,
|
||||
StandingRow,
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
BreakdownItem,
|
||||
} from '@/shared'
|
||||
import { computeEventScore, type ScorableRow } from './scoring'
|
||||
|
||||
/**
|
||||
* 后端未就绪时的本地示例数据源。
|
||||
*
|
||||
* 只覆盖管理端真正用到的读 / 写路径,并在内存里模拟草稿 → 发布 → 积分榜变化,
|
||||
* 保证「后端没起来」时录入页依然能完整走通交互。所有界面必须显式标注「示例数据」。
|
||||
*/
|
||||
|
||||
const ATHLETES = ['张伟', '李强', '王芳', '刘洋', '陈晨', '赵磊', '孙倩', '周涛', '吴敏', '郑浩']
|
||||
|
||||
const TEAMS: Team[] = [
|
||||
['JG', '机关本部', '机关'],
|
||||
['F1', '第一分公司', '一分公司'],
|
||||
['F2', '第二分公司', '二分公司'],
|
||||
['F3', '第三分公司', '三分公司'],
|
||||
['F4', '第四分公司', '四分公司'],
|
||||
['F5', '第五分公司', '五分公司'],
|
||||
['F6', '第六分公司', '六分公司'],
|
||||
].map(([code, name, short], index) => ({
|
||||
id: index + 1,
|
||||
code: code as string,
|
||||
name: name as string,
|
||||
shortName: short as string,
|
||||
logoPath: null,
|
||||
color: ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C'][index] ?? null,
|
||||
sortOrder: index + 1,
|
||||
active: true,
|
||||
}))
|
||||
|
||||
const SPORTS: Sport[] = [
|
||||
{ id: 1, code: 'TJ', name: '田径', shortName: '田径', defaultProfileId: 1, sortOrder: 1, active: true },
|
||||
{ id: 2, code: 'QL', name: '球类', shortName: '球类', defaultProfileId: 2, sortOrder: 2, active: true },
|
||||
{ id: 3, code: 'QW', name: '趣味', shortName: '趣味', defaultProfileId: 2, sortOrder: 3, active: true },
|
||||
]
|
||||
|
||||
const PROFILES: ScoringProfile[] = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'individual',
|
||||
name: '个人名次积分',
|
||||
tieMode: 'skip',
|
||||
recordBonusMode: 'max',
|
||||
awardRanks: 6,
|
||||
lastPlaceZero: false,
|
||||
rules: [
|
||||
{ rank: 1, points: 10 },
|
||||
{ rank: 2, points: 8 },
|
||||
{ rank: 3, points: 6 },
|
||||
{ rank: 4, points: 3 },
|
||||
{ rank: 5, points: 2 },
|
||||
{ rank: 6, points: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'team',
|
||||
name: '团体名次积分',
|
||||
tieMode: 'skip',
|
||||
recordBonusMode: 'max',
|
||||
awardRanks: 6,
|
||||
lastPlaceZero: false,
|
||||
rules: [
|
||||
{ rank: 1, points: 15 },
|
||||
{ rank: 2, points: 10 },
|
||||
{ rank: 3, points: 8 },
|
||||
{ rank: 4, points: 4 },
|
||||
{ rank: 5, points: 2 },
|
||||
{ rank: 6, points: 1 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface EventSeed {
|
||||
id: number
|
||||
sportId: number
|
||||
code: string
|
||||
name: string
|
||||
eventType: Event['eventType']
|
||||
resultMode: Event['resultMode']
|
||||
resultType: ResultType
|
||||
compare: Event['compare']
|
||||
scored: boolean
|
||||
categoryName: string
|
||||
/** 已发布的成绩:单位序号(从 1 开始)+ 成绩文本 + 手动名次(团体项目用)。 */
|
||||
published?: Array<[number, string] | [number, string, number]>
|
||||
}
|
||||
|
||||
const EVENT_SEEDS: EventSeed[] = [
|
||||
{
|
||||
id: 101,
|
||||
sportId: 1,
|
||||
code: 'M100',
|
||||
name: '男子 100 米',
|
||||
eventType: 'individual',
|
||||
resultMode: 'auto',
|
||||
resultType: 'time',
|
||||
compare: 'asc',
|
||||
scored: true,
|
||||
categoryName: '男子组',
|
||||
published: [
|
||||
[1, '11.58'],
|
||||
[2, '11.92'],
|
||||
[3, '12.01'],
|
||||
[4, '12.20'],
|
||||
[5, '12.35'],
|
||||
[6, '12.48'],
|
||||
[7, '12.60'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
sportId: 1,
|
||||
code: 'W100',
|
||||
name: '女子 100 米',
|
||||
eventType: 'individual',
|
||||
resultMode: 'auto',
|
||||
resultType: 'time',
|
||||
compare: 'asc',
|
||||
scored: true,
|
||||
categoryName: '女子组',
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
sportId: 1,
|
||||
code: 'M800',
|
||||
name: '男子 800 米',
|
||||
eventType: 'individual',
|
||||
resultMode: 'auto',
|
||||
resultType: 'time',
|
||||
compare: 'asc',
|
||||
scored: true,
|
||||
categoryName: '男子组',
|
||||
published: [
|
||||
[2, '2:14.30'],
|
||||
[1, '2:16.05'],
|
||||
[4, '2:18.77'],
|
||||
[3, '2:20.10'],
|
||||
[5, '2:22.44'],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
sportId: 1,
|
||||
code: 'LJ',
|
||||
name: '跳远',
|
||||
eventType: 'individual',
|
||||
resultMode: 'auto',
|
||||
resultType: 'distance',
|
||||
compare: 'desc',
|
||||
scored: true,
|
||||
categoryName: '男子组',
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
sportId: 3,
|
||||
code: 'PU',
|
||||
name: '引体向上',
|
||||
eventType: 'individual',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
scored: true,
|
||||
categoryName: '男子组',
|
||||
},
|
||||
{
|
||||
id: 106,
|
||||
sportId: 2,
|
||||
code: 'BB',
|
||||
name: '篮球(团体)',
|
||||
eventType: 'team',
|
||||
resultMode: 'manual',
|
||||
resultType: 'score',
|
||||
compare: 'none',
|
||||
scored: true,
|
||||
categoryName: '混合组',
|
||||
published: [
|
||||
[3, '第一名', 1],
|
||||
[1, '第二名', 2],
|
||||
[5, '第三名', 3],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 107,
|
||||
sportId: 3,
|
||||
code: 'FUN',
|
||||
name: '趣味接力(不计分)',
|
||||
eventType: 'team',
|
||||
resultMode: 'manual',
|
||||
resultType: 'none',
|
||||
compare: 'none',
|
||||
scored: false,
|
||||
categoryName: '混合组',
|
||||
},
|
||||
]
|
||||
|
||||
const SETTINGS: EventSettings = {
|
||||
event: {
|
||||
name: '2026 职工运动会',
|
||||
subtitle: '示例数据',
|
||||
host: '示例主办方',
|
||||
logoPath: null,
|
||||
primaryColor: '#3482FF',
|
||||
},
|
||||
display: {
|
||||
background: null,
|
||||
logo: null,
|
||||
title: '2026 职工运动会',
|
||||
subtitle: '积分榜',
|
||||
primaryColor: '#3482FF',
|
||||
rotateSeconds: 15,
|
||||
},
|
||||
standings: { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true },
|
||||
defaultProfileId: 1,
|
||||
requireApproval: false,
|
||||
}
|
||||
|
||||
function parseValue(text: string, type: ResultType): number | null {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return null
|
||||
if (type === 'time') {
|
||||
const parts = trimmed.split(':')
|
||||
const numbers = parts.map((part) => Number(part))
|
||||
if (numbers.some((value) => !Number.isFinite(value))) return null
|
||||
if (numbers.length === 1) return numbers[0] ?? null
|
||||
return (numbers[0] ?? 0) * 60 + (numbers[1] ?? 0)
|
||||
}
|
||||
const cleaned = trimmed.replace(/[^\d.+-]/g, '')
|
||||
if (cleaned === '') return null
|
||||
const numeric = Number(cleaned)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
function unitOf(type: ResultType): string {
|
||||
if (type === 'time') return 's'
|
||||
if (type === 'distance') return 'm'
|
||||
if (type === 'count') return '次'
|
||||
if (type === 'score') return 'pts'
|
||||
return ''
|
||||
}
|
||||
|
||||
class MockDb {
|
||||
private readonly teams = TEAMS
|
||||
private readonly sports = SPORTS
|
||||
private readonly profiles = PROFILES
|
||||
private readonly events: Event[]
|
||||
private readonly categories: EventCategory[]
|
||||
private readonly channels: ScoreChannel[]
|
||||
private readonly entries: Entry[] = []
|
||||
private readonly results = new Map<number, Result>()
|
||||
private readonly logs: AuditLog[] = []
|
||||
private nextResultId = 9000
|
||||
private nextLogId = 1
|
||||
|
||||
constructor() {
|
||||
this.events = EVENT_SEEDS.map((seed, index) => ({
|
||||
id: seed.id,
|
||||
sportId: seed.sportId,
|
||||
code: seed.code,
|
||||
name: seed.name,
|
||||
eventType: seed.eventType,
|
||||
resultMode: seed.resultMode,
|
||||
resultType: seed.resultType,
|
||||
compare: seed.compare,
|
||||
recordEligible: seed.resultType === 'time' || seed.resultType === 'distance',
|
||||
scored: seed.scored,
|
||||
multiplier: 1,
|
||||
status: seed.published === undefined ? 'pending' : 'published',
|
||||
scheduledAt: null,
|
||||
venue: seed.sportId === 1 ? '田径场' : '体育馆',
|
||||
sortOrder: index + 1,
|
||||
remark: null,
|
||||
}))
|
||||
|
||||
this.categories = EVENT_SEEDS.map((seed) => ({
|
||||
id: seed.id * 10 + 1,
|
||||
eventId: seed.id,
|
||||
name: seed.categoryName,
|
||||
multiplier: 1,
|
||||
gender: seed.categoryName.startsWith('男子') ? '男' : seed.categoryName.startsWith('女子') ? '女' : null,
|
||||
ageMin: null,
|
||||
ageMax: null,
|
||||
sortOrder: 1,
|
||||
}))
|
||||
|
||||
this.channels = EVENT_SEEDS.map((seed) => ({
|
||||
id: seed.id * 10 + 1,
|
||||
eventId: seed.id,
|
||||
scope: seed.eventType === 'individual' ? 'athlete' : 'team',
|
||||
basis: seed.resultMode === 'manual' ? 'manual' : 'result',
|
||||
profileId: seed.eventType === 'individual' ? 1 : 2,
|
||||
sortOrder: 1,
|
||||
}))
|
||||
|
||||
for (const seed of EVENT_SEEDS) this.seedEntries(seed)
|
||||
for (const seed of EVENT_SEEDS) this.seedResults(seed)
|
||||
}
|
||||
|
||||
private seedEntries(seed: EventSeed): void {
|
||||
const categoryId = seed.id * 10 + 1
|
||||
this.teams.forEach((team, index) => {
|
||||
const entryId = seed.id * 1000 + team.id * 10 + 1
|
||||
const athleteName = seed.eventType === 'individual' ? ATHLETES[(seed.id + index) % ATHLETES.length] ?? '运动员' : null
|
||||
this.entries.push({
|
||||
id: entryId,
|
||||
eventId: seed.id,
|
||||
categoryId,
|
||||
teamId: team.id,
|
||||
athleteId: seed.eventType === 'individual' ? entryId : null,
|
||||
memberNames: seed.eventType === 'individual' ? null : [`${team.shortName ?? team.name}代表队`],
|
||||
bib: seed.eventType === 'individual' ? String(entryId % 1000) : null,
|
||||
seed: null,
|
||||
status: 'registered',
|
||||
remark: null,
|
||||
athleteName,
|
||||
teamCode: team.code,
|
||||
teamName: team.name,
|
||||
shortName: team.shortName,
|
||||
categoryName: seed.categoryName,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private seedResults(seed: EventSeed): void {
|
||||
if (seed.published === undefined) return
|
||||
const categoryId = seed.id * 10 + 1
|
||||
const rows: DraftResultInput[] = seed.published.map((entry) => ({
|
||||
entryId: seed.id * 1000 + entry[0] * 10 + 1,
|
||||
resultText: entry[1],
|
||||
resultValue: parseValue(entry[1], seed.resultType),
|
||||
rank: entry.length === 3 ? entry[2] : null,
|
||||
status: 'ok',
|
||||
}))
|
||||
const saved = this.writeResults(seed.id, categoryId, rows, 'published')
|
||||
for (const result of saved) this.results.set(result.entryId, result)
|
||||
}
|
||||
|
||||
private eventOf(eventId: number): Event {
|
||||
const event = this.events.find((item) => item.id === eventId)
|
||||
if (event === undefined) throw new Error(`示例数据里没有项目 ${eventId}`)
|
||||
return event
|
||||
}
|
||||
|
||||
private scorable(eventId: number, categoryId: number | undefined): ScorableRow[] {
|
||||
return this.entries
|
||||
.filter((entry) => entry.eventId === eventId && (categoryId === undefined || entry.categoryId === categoryId))
|
||||
.map((entry) => {
|
||||
const result = this.results.get(entry.id)
|
||||
return {
|
||||
entryId: entry.id,
|
||||
teamId: entry.teamId,
|
||||
resultValue: result?.resultValue ?? null,
|
||||
rank: result?.rank ?? null,
|
||||
status: result?.status ?? 'ok',
|
||||
isTie: result?.isTie ?? false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private scoreContext(eventId: number, categoryId: number | undefined) {
|
||||
const event = this.eventOf(eventId)
|
||||
const category = this.categories.find((item) => item.id === categoryId) ?? null
|
||||
return {
|
||||
event,
|
||||
category,
|
||||
channels: this.channels.filter((item) => item.eventId === eventId),
|
||||
profiles: this.profiles,
|
||||
weight: SETTINGS.standings.individualToTeamWeight,
|
||||
}
|
||||
}
|
||||
|
||||
private writeResults(
|
||||
eventId: number,
|
||||
categoryId: number | undefined,
|
||||
inputs: readonly DraftResultInput[],
|
||||
state: Result['state'],
|
||||
): Result[] {
|
||||
const event = this.eventOf(eventId)
|
||||
const unit = unitOf(event.resultType)
|
||||
const rows: ScorableRow[] = inputs.map((input) => {
|
||||
const existing = this.results.get(input.entryId)
|
||||
const entry = this.entries.find((item) => item.id === input.entryId)
|
||||
const resultValue =
|
||||
input.resultValue !== undefined
|
||||
? input.resultValue
|
||||
: input.resultText !== undefined
|
||||
? parseValue(input.resultText, event.resultType)
|
||||
: (existing?.resultValue ?? null)
|
||||
return {
|
||||
entryId: input.entryId,
|
||||
teamId: entry?.teamId ?? 0,
|
||||
resultValue,
|
||||
rank: input.rank ?? existing?.rank ?? null,
|
||||
status: input.status ?? existing?.status ?? 'ok',
|
||||
isTie: input.isTie ?? existing?.isTie ?? false,
|
||||
}
|
||||
})
|
||||
const ranks = computeEventScore(rows, this.scoreContext(eventId, categoryId)).ranks
|
||||
|
||||
return inputs.map((input) => {
|
||||
const existing = this.results.get(input.entryId)
|
||||
const resultText = input.resultText ?? existing?.resultText ?? ''
|
||||
const resultValue =
|
||||
input.resultValue !== undefined
|
||||
? input.resultValue
|
||||
: parseValue(resultText, event.resultType)
|
||||
const status: ResultStatus = input.status ?? existing?.status ?? 'ok'
|
||||
const rank = event.resultMode === 'manual' || event.compare === 'none'
|
||||
? (input.rank ?? existing?.rank ?? null)
|
||||
: (status === 'ok' ? (ranks.get(input.entryId) ?? null) : null)
|
||||
const result: Result = {
|
||||
id: existing?.id ?? (this.nextResultId += 1),
|
||||
eventId,
|
||||
entryId: input.entryId,
|
||||
rank,
|
||||
resultText,
|
||||
resultValue,
|
||||
unit,
|
||||
isTie: input.isTie ?? existing?.isTie ?? false,
|
||||
pointsOverride: input.pointsOverride ?? null,
|
||||
status,
|
||||
state,
|
||||
recordedBy: state === 'published' ? 1 : null,
|
||||
recordedAt: new Date().toISOString(),
|
||||
verifiedBy: state === 'published' ? 1 : null,
|
||||
verifiedAt: state === 'published' ? new Date().toISOString() : null,
|
||||
note: input.note ?? existing?.note ?? null,
|
||||
points: null,
|
||||
}
|
||||
this.results.set(input.entryId, result)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
private log(action: string, entityType: string, entityId: number, detail: string): void {
|
||||
this.logs.unshift({
|
||||
id: (this.nextLogId += 1),
|
||||
userId: 1,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
beforeJson: null,
|
||||
afterJson: JSON.stringify({ detail }),
|
||||
ip: '127.0.0.1',
|
||||
requestId: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── 对外方法 ────────────────────────────────────────────────────────────
|
||||
|
||||
bootstrap(): {
|
||||
settings: EventSettings
|
||||
teams: Team[]
|
||||
sports: Sport[]
|
||||
events: Event[]
|
||||
profiles: ScoringProfile[]
|
||||
standings: StandingRow[]
|
||||
athleteStandings: AthleteStanding[]
|
||||
} {
|
||||
return {
|
||||
settings: SETTINGS,
|
||||
teams: this.teams,
|
||||
sports: this.sports,
|
||||
events: this.events,
|
||||
profiles: this.profiles,
|
||||
standings: this.standings(),
|
||||
athleteStandings: this.athleteStandings(),
|
||||
}
|
||||
}
|
||||
|
||||
eventDetail(id: number): Event {
|
||||
const event = this.eventOf(id)
|
||||
return {
|
||||
...event,
|
||||
categories: this.categories.filter((item) => item.eventId === id),
|
||||
channels: this.channels.filter((item) => item.eventId === id),
|
||||
}
|
||||
}
|
||||
|
||||
listEntries(eventId: number): EntryWithResult[] {
|
||||
return this.entries
|
||||
.filter((entry) => entry.eventId === eventId)
|
||||
.map((entry) => {
|
||||
const result = this.results.get(entry.id) ?? null
|
||||
return { ...entry, result: result === null ? null : this.withPoints(result) }
|
||||
})
|
||||
}
|
||||
|
||||
saveDraft(payload: SaveDraftPayload): Result[] {
|
||||
const saved = this.writeResults(payload.eventId, payload.categoryId, payload.results, 'draft')
|
||||
this.log('result.draft', 'result', payload.eventId, `保存草稿 ${saved.length} 条`)
|
||||
return saved.map((result) => this.withPoints(result))
|
||||
}
|
||||
|
||||
publish(payload: PublishPayload): Result[] {
|
||||
const targets = this.entries.filter(
|
||||
(entry) =>
|
||||
entry.eventId === payload.eventId &&
|
||||
(payload.categoryId === undefined || entry.categoryId === payload.categoryId),
|
||||
)
|
||||
const changed = targets
|
||||
.map((entry) => this.results.get(entry.id))
|
||||
.filter((result): result is Result => result !== undefined)
|
||||
for (const result of changed) {
|
||||
result.state = 'published'
|
||||
result.recordedAt = new Date().toISOString()
|
||||
result.verifiedAt = result.recordedAt
|
||||
}
|
||||
const event = this.eventOf(payload.eventId)
|
||||
event.status = 'published'
|
||||
this.log('result.publish', 'event', payload.eventId, `发布 ${changed.length} 条成绩`)
|
||||
return changed.map((result) => this.withPoints(result))
|
||||
}
|
||||
|
||||
unpublish(payload: PublishPayload): Result[] {
|
||||
const targets = this.entries.filter(
|
||||
(entry) =>
|
||||
entry.eventId === payload.eventId &&
|
||||
(payload.categoryId === undefined || entry.categoryId === payload.categoryId),
|
||||
)
|
||||
const changed = targets
|
||||
.map((entry) => this.results.get(entry.id))
|
||||
.filter((result): result is Result => result !== undefined)
|
||||
for (const result of changed) result.state = 'draft'
|
||||
const event = this.eventOf(payload.eventId)
|
||||
event.status = 'ongoing'
|
||||
this.log('result.unpublish', 'event', payload.eventId, `撤销发布 ${changed.length} 条成绩`)
|
||||
return changed.map((result) => this.withPoints(result))
|
||||
}
|
||||
|
||||
private withPoints(result: Result): Result {
|
||||
const points = computeEventScore(
|
||||
this.scorable(result.eventId, undefined),
|
||||
this.scoreContext(result.eventId, undefined),
|
||||
).rowPoints.get(result.entryId)
|
||||
return { ...result, points: points ?? 0 }
|
||||
}
|
||||
|
||||
standings(): StandingRow[] {
|
||||
const totals = new Map<number, { points: number; gold: number; silver: number; bronze: number }>()
|
||||
for (const team of this.teams) totals.set(team.id, { points: 0, gold: 0, silver: 0, bronze: 0 })
|
||||
|
||||
for (const event of this.events) {
|
||||
const scorable = this.scorable(event.id, undefined)
|
||||
const score = computeEventScore(scorable, this.scoreContext(event.id, undefined))
|
||||
for (const [teamId, points] of score.teamPoints) {
|
||||
const bucket = totals.get(teamId)
|
||||
if (bucket !== undefined) bucket.points += points
|
||||
}
|
||||
for (const row of scorable) {
|
||||
if (row.status !== 'ok') continue
|
||||
const rank = score.ranks.get(row.entryId)
|
||||
const bucket = totals.get(row.teamId)
|
||||
if (bucket === undefined || rank === null) continue
|
||||
if (rank === 1) bucket.gold += 1
|
||||
else if (rank === 2) bucket.silver += 1
|
||||
else if (rank === 3) bucket.bronze += 1
|
||||
}
|
||||
}
|
||||
|
||||
return this.teams
|
||||
.map((team) => {
|
||||
const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 }
|
||||
return {
|
||||
teamId: team.id,
|
||||
teamCode: team.code,
|
||||
teamName: team.name,
|
||||
shortName: team.shortName,
|
||||
color: team.color,
|
||||
logoPath: team.logoPath,
|
||||
rank: 0,
|
||||
gold: bucket.gold,
|
||||
silver: bucket.silver,
|
||||
bronze: bucket.bronze,
|
||||
medalTotal: bucket.gold + bucket.silver + bucket.bronze,
|
||||
totalPoints: round(bucket.points),
|
||||
rankChange: null,
|
||||
}
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.totalPoints - a.totalPoints ||
|
||||
b.gold - a.gold ||
|
||||
b.silver - a.silver ||
|
||||
b.bronze - a.bronze ||
|
||||
a.teamCode.localeCompare(b.teamCode),
|
||||
)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }))
|
||||
}
|
||||
|
||||
athleteStandings(): AthleteStanding[] {
|
||||
const rows: AthleteStanding[] = []
|
||||
for (const event of this.events) {
|
||||
if (event.eventType !== 'individual') continue
|
||||
const scorable = this.scorable(event.id, undefined)
|
||||
const score = computeEventScore(scorable, this.scoreContext(event.id, undefined))
|
||||
for (const entry of this.entries.filter((item) => item.eventId === event.id)) {
|
||||
const points = score.rowPoints.get(entry.id) ?? 0
|
||||
if (points === 0) continue
|
||||
const rank = score.ranks.get(entry.id) ?? null
|
||||
rows.push({
|
||||
rank: 0,
|
||||
athleteId: entry.athleteId ?? entry.id,
|
||||
athleteName: entry.athleteName ?? '运动员',
|
||||
gender: null,
|
||||
teamId: entry.teamId,
|
||||
teamCode: entry.teamCode ?? '',
|
||||
teamName: entry.teamName ?? '',
|
||||
shortName: entry.shortName ?? null,
|
||||
totalPoints: points,
|
||||
gold: rank === 1 ? 1 : 0,
|
||||
silver: rank === 2 ? 1 : 0,
|
||||
bronze: rank === 3 ? 1 : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
.sort((a, b) => b.totalPoints - a.totalPoints || a.athleteName.localeCompare(b.athleteName))
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }))
|
||||
}
|
||||
|
||||
breakdown(teamId: number): TeamBreakdown {
|
||||
const team = this.teams.find((item) => item.id === teamId)
|
||||
const items: BreakdownItem[] = []
|
||||
for (const event of this.events) {
|
||||
const score = computeEventScore(
|
||||
this.scorable(event.id, undefined),
|
||||
this.scoreContext(event.id, undefined),
|
||||
)
|
||||
for (const entry of this.entries.filter((item) => item.eventId === event.id && item.teamId === teamId)) {
|
||||
const result = this.results.get(entry.id)
|
||||
if (result === undefined || result.state !== 'published') continue
|
||||
const points = score.rowPoints.get(entry.id) ?? 0
|
||||
items.push({
|
||||
ledgerId: result.id,
|
||||
sourceType: 'result',
|
||||
eventId: event.id,
|
||||
eventCode: event.code,
|
||||
eventName: event.name,
|
||||
categoryName: entry.categoryName ?? null,
|
||||
athleteId: entry.athleteId,
|
||||
athleteName: entry.athleteName ?? null,
|
||||
rank: score.ranks.get(entry.id) ?? null,
|
||||
resultText: result.resultText,
|
||||
points,
|
||||
createdAt: result.recordedAt ?? new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
teamId,
|
||||
teamCode: team?.code ?? '',
|
||||
teamName: team?.name ?? '未知单位',
|
||||
totalPoints: round(items.reduce((sum, item) => sum + item.points, 0)),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
records(): RecordEntry[] {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
eventId: 101,
|
||||
level: 'meet',
|
||||
resultValue: 11.2,
|
||||
resultText: '11.20',
|
||||
holderName: '示例纪录保持者',
|
||||
holderTeamId: 1,
|
||||
achievedAt: '2025-09-01',
|
||||
isCurrent: true,
|
||||
source: 'history',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
adjustments(): Page<PointAdjustment> {
|
||||
return { items: [], total: 0, page: 1, size: 20 }
|
||||
}
|
||||
|
||||
auditLogs(page = 1, size = 20): Page<AuditLog> {
|
||||
return { items: this.logs.slice((page - 1) * size, page * size), total: this.logs.length, page, size }
|
||||
}
|
||||
|
||||
login(username: string): CurrentUser {
|
||||
return {
|
||||
id: 1,
|
||||
username,
|
||||
displayName: username === '' ? '示例用户' : username,
|
||||
role: 'admin',
|
||||
lastLoginAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
health(): HealthInfo {
|
||||
return { version: 'mock' }
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Number(value.toFixed(2))
|
||||
}
|
||||
|
||||
export const mockDb = new MockDb()
|
||||
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
CompareMode,
|
||||
Event,
|
||||
EventCategory,
|
||||
ResultStatus,
|
||||
ScoreChannel,
|
||||
ScoringProfile,
|
||||
TieMode,
|
||||
} from '@/shared'
|
||||
|
||||
/**
|
||||
* 名次与积分的纯函数实现。
|
||||
*
|
||||
* 录入页的「实时预览」和 mock 数据源的发布结果必须用同一套算法,
|
||||
* 否则演示态与真实态算出的分会不一致,预览就失去意义。
|
||||
* 服务端就绪后这里只用于预览,真值仍以 /api/standings 为准。
|
||||
*/
|
||||
|
||||
export interface ScorableRow {
|
||||
entryId: number
|
||||
teamId: number
|
||||
resultValue: number | null
|
||||
rank: number | null
|
||||
status: ResultStatus
|
||||
isTie: boolean
|
||||
}
|
||||
|
||||
const EPSILON = 1e-9
|
||||
|
||||
function isRanked(row: ScorableRow): boolean {
|
||||
return row.status === 'ok' && row.resultValue !== null
|
||||
}
|
||||
|
||||
/** 按比较方向排序并赋名次;并列按 tieMode 处理。manual / compare=none 时直接用录入的名次。 */
|
||||
export function assignRanks(
|
||||
rows: readonly ScorableRow[],
|
||||
compare: CompareMode,
|
||||
tieMode: TieMode,
|
||||
manual: boolean,
|
||||
): Map<number, number | null> {
|
||||
const result = new Map<number, number | null>()
|
||||
const ranked = rows.filter(isRanked)
|
||||
|
||||
if (manual || compare === 'none') {
|
||||
for (const row of rows) result.set(row.entryId, row.status === 'ok' ? row.rank : null)
|
||||
return result
|
||||
}
|
||||
|
||||
const direction = compare === 'asc' ? 1 : -1
|
||||
const sorted = [...ranked].sort(
|
||||
(a, b) => direction * ((a.resultValue ?? 0) - (b.resultValue ?? 0)),
|
||||
)
|
||||
|
||||
let index = 0
|
||||
while (index < sorted.length) {
|
||||
const group: ScorableRow[] = [sorted[index] as ScorableRow]
|
||||
const head = group[0] as ScorableRow
|
||||
let cursor = index + 1
|
||||
while (
|
||||
cursor < sorted.length &&
|
||||
Math.abs((sorted[cursor] as ScorableRow).resultValue! - head.resultValue!) < EPSILON
|
||||
) {
|
||||
group.push(sorted[cursor] as ScorableRow)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
const position = index + 1
|
||||
const tied = group.length > 1
|
||||
let rank = position
|
||||
if (tied && tieMode === 'average') {
|
||||
rank = (position + (position + group.length - 1)) / 2
|
||||
}
|
||||
// skip / higher:并列者都拿靠前的名次,后续名次顺延(02 第 2.2 节)。
|
||||
|
||||
for (const row of group) result.set(row.entryId, rank)
|
||||
index = cursor
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (!result.has(row.entryId)) result.set(row.entryId, null)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 名次 → 分值;并列平均产生小数名次时,取相邻两档的均值。 */
|
||||
export function pointsForRank(rank: number | null, profile: ScoringProfile | null): number {
|
||||
if (rank === null || profile === null || !Number.isFinite(rank)) return 0
|
||||
const exact = profile.rules.find((rule) => rule.rank === rank)
|
||||
if (exact !== undefined) return exact.points
|
||||
|
||||
const lower = profile.rules.find((rule) => rule.rank === Math.floor(rank))
|
||||
const upper = profile.rules.find((rule) => rule.rank === Math.ceil(rank))
|
||||
if (lower === undefined && upper === undefined) return 0
|
||||
if (lower === undefined) return upper?.points ?? 0
|
||||
if (upper === undefined) return lower.points
|
||||
return (lower.points + upper.points) / 2
|
||||
}
|
||||
|
||||
export interface ChannelPlan {
|
||||
athleteProfile: ScoringProfile | null
|
||||
teamProfile: ScoringProfile | null
|
||||
}
|
||||
|
||||
/** 一个项目可挂多个通道;预览只关心「个人表」和「团体表」两张。 */
|
||||
export function planChannels(
|
||||
channels: readonly ScoreChannel[],
|
||||
profiles: readonly ScoringProfile[],
|
||||
): ChannelPlan {
|
||||
const pick = (scope: ScoreChannel['scope']): ScoringProfile | null => {
|
||||
const channel = channels.find((item) => item.scope === scope)
|
||||
if (channel === undefined) return null
|
||||
return profiles.find((profile) => profile.id === channel.profileId) ?? null
|
||||
}
|
||||
return { athleteProfile: pick('athlete'), teamProfile: pick('team') }
|
||||
}
|
||||
|
||||
export interface EventScoreResult {
|
||||
/** 每行「直接产生」的积分,用于表格里的积分列。 */
|
||||
rowPoints: Map<number, number>
|
||||
/** 每行名次,用于自动排名展示。 */
|
||||
ranks: Map<number, number | null>
|
||||
/** 按单位聚合的得分增量,用于预览面板。 */
|
||||
teamPoints: Map<number, number>
|
||||
}
|
||||
|
||||
export interface ScoreContext {
|
||||
event: Event
|
||||
category: EventCategory | null
|
||||
channels: readonly ScoreChannel[]
|
||||
profiles: readonly ScoringProfile[]
|
||||
/** standings.individualToTeamWeight,个人分折算到单位的系数。 */
|
||||
weight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一组成绩的积分。
|
||||
*
|
||||
* 个人项目:行分记给个人,单位获得 `个人分 × 系数`;
|
||||
* 团体 / 对抗项目:行分直接记给单位(02 第 2.4 节)。
|
||||
*/
|
||||
export function computeEventScore(
|
||||
rows: readonly ScorableRow[],
|
||||
context: ScoreContext,
|
||||
): EventScoreResult {
|
||||
const manual = context.event.resultMode === 'manual'
|
||||
const ranks = assignRanks(rows, context.event.compare, tieModeOf(context), manual)
|
||||
const plan = planChannels(context.channels, context.profiles)
|
||||
const multiplier = context.event.multiplier * (context.category?.multiplier ?? 1)
|
||||
const isIndividual = context.event.eventType === 'individual'
|
||||
|
||||
const rowPoints = new Map<number, number>()
|
||||
const teamPoints = new Map<number, number>()
|
||||
const addTeam = (teamId: number, points: number): void => {
|
||||
if (points === 0) return
|
||||
teamPoints.set(teamId, (teamPoints.get(teamId) ?? 0) + points)
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const rank = ranks.get(row.entryId) ?? null
|
||||
const profile = isIndividual
|
||||
? (plan.athleteProfile ?? plan.teamProfile)
|
||||
: (plan.teamProfile ?? plan.athleteProfile)
|
||||
const points = pointsForRank(rank, profile) * multiplier
|
||||
rowPoints.set(row.entryId, points)
|
||||
|
||||
if (isIndividual) addTeam(row.teamId, points * context.weight)
|
||||
else addTeam(row.teamId, points)
|
||||
}
|
||||
|
||||
return { rowPoints, ranks, teamPoints }
|
||||
}
|
||||
|
||||
function tieModeOf(context: ScoreContext): TieMode {
|
||||
const profile = planChannels(context.channels, context.profiles).athleteProfile
|
||||
return profile?.tieMode ?? 'skip'
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import MxIcon from './MxIcon.vue'
|
||||
|
||||
/**
|
||||
* 危险操作二次确认(07 第 7 节)。
|
||||
* 只负责「确认」这一件事,loading 态由调用方通过 `pending` 传入,避免弹窗自己管请求。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
details?: string[]
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
tone?: 'info' | 'warning' | 'danger'
|
||||
pending?: boolean
|
||||
}>(),
|
||||
{
|
||||
details: () => [],
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
tone: 'warning',
|
||||
pending: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (!props.open) return
|
||||
if (event.key === 'Escape') emit('cancel')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-modal-overlay" :class="{ open }" @click.self="emit('cancel')">
|
||||
<div class="mx-modal" role="dialog" aria-modal="true">
|
||||
<div class="mx-modal-head">
|
||||
<span class="mx-modal-title">{{ title }}</span>
|
||||
<button class="mx-modal-close" type="button" @click="emit('cancel')">
|
||||
<MxIcon name="close" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-modal-body">
|
||||
<div class="mx-modal-confirm-icon" :class="tone">
|
||||
<MxIcon :name="tone === 'danger' || tone === 'warning' ? 'warning' : 'check'" :size="24" />
|
||||
</div>
|
||||
<p class="mx-modal-confirm-text">{{ message }}</p>
|
||||
<ul v-if="details.length > 0" class="confirm-details">
|
||||
<li v-for="(line, index) in details" :key="index">{{ line }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mx-modal-foot">
|
||||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn"
|
||||
:class="tone === 'danger' ? 'mx-btn-danger' : 'mx-btn-primary'"
|
||||
type="button"
|
||||
:disabled="pending"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ pending ? '处理中…' : confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.confirm-details {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.confirm-details li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.confirm-details li::before {
|
||||
content: '·';
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ICON_PATHS, type IconName } from './icons'
|
||||
|
||||
const props = withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
|
||||
const d = computed(() => ICON_PATHS[props.name])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path :d="d" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; subtitle?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="mx-page-head page-head">
|
||||
<div>
|
||||
<h1 class="mx-page-title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="mx-page-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="page-head-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { useToastStore, type ToastKind } from '../stores/toast'
|
||||
import MxIcon from './MxIcon.vue'
|
||||
import type { IconName } from './icons'
|
||||
|
||||
const toast = useToastStore()
|
||||
|
||||
const ICONS: Record<ToastKind, IconName> = {
|
||||
success: 'check',
|
||||
warning: 'warning',
|
||||
error: 'warning',
|
||||
info: 'bell',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-toast-container">
|
||||
<div
|
||||
v-for="item in toast.items"
|
||||
:key="item.id"
|
||||
class="mx-toast show"
|
||||
:class="`mx-toast-${item.kind}`"
|
||||
role="status"
|
||||
>
|
||||
<span class="mx-toast-icon"><MxIcon :name="ICONS[item.kind]" :size="18" /></span>
|
||||
<span class="mx-toast-text">
|
||||
{{ item.message }}
|
||||
<small v-if="item.detail">{{ item.detail }}</small>
|
||||
</span>
|
||||
<button class="mx-toast-close" type="button" @click="toast.dismiss(item.id)">
|
||||
<MxIcon name="close" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mx-toast {
|
||||
position: relative;
|
||||
}
|
||||
.mx-toast-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.mx-toast-text small {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
/** 管理端用到的内联图标路径。现场无外网,不引图标库。 */
|
||||
export const ICON_PATHS = {
|
||||
dashboard: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z',
|
||||
entry: 'M4 5h16M4 12h10M4 19h7M17 15l3 3-3 3',
|
||||
review: 'M5 4h14v16H5zM8 9h8M8 13h8M8 17h4',
|
||||
record: 'M8 21h8M12 17v4M6 4h12v6a6 6 0 0 1-12 0V4Z',
|
||||
adjust: 'M4 8h10M18 8h2M4 16h2M10 16h10M15 5v6M9 13v6',
|
||||
standings: 'M4 20V10h4v10H4Zm6 0V4h4v16h-4Zm6 0v-7h4v7h-4Z',
|
||||
display: 'M3 4h18v12H3zM8 20h8M12 16v4',
|
||||
database:
|
||||
'M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3Zm8 3v12c0 1.7-3.6 3-8 3s-8-1.3-8-3V6M20 12c0 1.7-3.6 3-8 3s-8-1.3-8-3',
|
||||
export: 'M12 3v12M8 11l4 4 4-4M4 19h16',
|
||||
history: 'M4 12a8 8 0 1 0 3-6.2M4 4v4h4M12 8v4l3 2',
|
||||
logs: 'M5 4h14v16H5zM8 8h8M8 12h8M8 16h5',
|
||||
system:
|
||||
'M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm8.4 3-1.8-1 .3-2.1-2-1.2-1.7 1.3-1.8-1 .2-2.1-2.3-.7-1 1.9-2.1-.2-1.2 2 1.3 1.7-1 1.8-2.1.2-.7 2.3 1.9 1-.2 2.1 2 1.2 1.7-1.3 1.8 1-.2 2.1 2.3.7 1-1.9 2.1.2 1.2-2-1.3-1.7 1-1.8 2.1-.2.7-2.3Z',
|
||||
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v4h-4',
|
||||
lock: 'M6 11h12v9H6zM9 11V8a3 3 0 0 1 6 0v3',
|
||||
unlock: 'M6 11h12v9H6zM9 11V8a3 3 0 0 1 5.7-1.4',
|
||||
bell: 'M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6ZM10 19a2 2 0 0 0 4 0',
|
||||
push: 'M12 20V6M8 10l4-4 4 4M4 20h16',
|
||||
close: 'M6 6l12 12M18 6 6 18',
|
||||
check: 'M5 12.5 10 17l9-10',
|
||||
warning: 'M12 4l9 16H3l9-16Zm0 6v4m0 3v.5',
|
||||
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.3-4.3',
|
||||
chevron: 'M8 10l4 4 4-4',
|
||||
logout: 'M15 4h4v16h-4M11 8l-4 4 4 4M7 12h9',
|
||||
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8a8 8 0 0 1 16 0',
|
||||
keyboard: 'M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10',
|
||||
} as const
|
||||
|
||||
export type IconName = keyof typeof ICON_PATHS
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import type { IconName } from '../components/icons'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
interface NavItem {
|
||||
name: string
|
||||
label: string
|
||||
icon: IconName
|
||||
badge?: 'pending'
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
section: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const NAV: NavGroup[] = [
|
||||
{ section: '总览', items: [{ name: 'dashboard', label: '仪表盘', icon: 'dashboard' }] },
|
||||
{
|
||||
section: '成绩',
|
||||
items: [
|
||||
{ name: 'entry', label: '成绩录入', icon: 'entry', badge: 'pending' },
|
||||
{ name: 'review', label: '成绩审核', icon: 'review' },
|
||||
{ name: 'records', label: '破纪录', icon: 'record' },
|
||||
{ name: 'adjustments', label: '积分调整', icon: 'adjust' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '榜单',
|
||||
items: [
|
||||
{ name: 'standings', label: '积分榜', icon: 'standings' },
|
||||
{ name: 'display', label: '大屏控制', icon: 'display' },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '数据',
|
||||
items: [
|
||||
{ name: 'base-data', label: '基础数据', icon: 'database' },
|
||||
{ name: 'export', label: '导出', icon: 'export' },
|
||||
{ name: 'history', label: '变更历史', icon: 'history' },
|
||||
{ name: 'logs', label: '日志', icon: 'logs' },
|
||||
],
|
||||
},
|
||||
{ section: '系统', items: [{ name: 'system', label: '系统设置', icon: 'system' }] },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const data = useEventStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const sidebarOpen = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
const currentTitle = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '管理面板'))
|
||||
const pendingCount = computed(() => data.events.filter((event) => event.status === 'pending').length)
|
||||
|
||||
const connection = computed(() => {
|
||||
if (data.isMock) return { tone: 'warning', text: '示例数据', hint: data.mockLabel ?? '' }
|
||||
if (data.realtimeStatus === 'online') {
|
||||
return { tone: 'success', text: '实时在线', hint: data.serverVersion === null ? '' : `版本 ${data.serverVersion}` }
|
||||
}
|
||||
if (data.realtimeStatus === 'connecting') return { tone: 'info', text: '连接中…', hint: '' }
|
||||
return { tone: 'error', text: '连接中断', hint: '写操作已停用' }
|
||||
})
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await Promise.all([data.load(), data.refreshStandings()])
|
||||
toast.success('数据已刷新')
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await auth.logout()
|
||||
toast.info('已退出登录,当前为只读访客视图')
|
||||
void router.push({ name: 'dashboard' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void auth.load()
|
||||
void data.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-app admin-shell">
|
||||
<aside class="mx-sidebar" :class="{ open: sidebarOpen }">
|
||||
<div class="mx-logo brand">
|
||||
<span class="mx-logo-icon"><MxIcon name="standings" :size="18" /></span>
|
||||
<span class="mx-logo-text">积分榜管理</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<div v-for="group in NAV" :key="group.section" class="nav-group">
|
||||
<div class="mx-sidebar-section">{{ group.section }}</div>
|
||||
<RouterLink
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
class="mx-nav-item"
|
||||
:class="{ active: route.name === item.name }"
|
||||
:to="{ name: item.name }"
|
||||
@click="sidebarOpen = false"
|
||||
>
|
||||
<MxIcon :name="item.icon" :size="17" />
|
||||
<span class="mx-nav-label">{{ item.label }}</span>
|
||||
<span v-if="item.badge === 'pending' && pendingCount > 0" class="mx-count">{{ pendingCount }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mx-sidebar-bottom">
|
||||
<div class="side-note">
|
||||
<MxIcon name="keyboard" :size="15" />
|
||||
<span>Ctrl+Enter 存草稿<br />Alt+Enter 发布</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="mx-desktop-shell">
|
||||
<header class="mx-desktop-topbar topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-icon menu-btn" type="button" @click="sidebarOpen = !sidebarOpen">
|
||||
<MxIcon name="logs" :size="18" />
|
||||
</button>
|
||||
<div class="mx-breadcrumb">
|
||||
<span>管理面板</span>
|
||||
<span class="mx-breadcrumb-sep">/</span>
|
||||
<span>{{ currentTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<span class="mx-badge" :class="`mx-badge-${connection.tone}`" :title="connection.hint">
|
||||
{{ connection.text }}
|
||||
</span>
|
||||
<span v-if="data.serverHealth" class="version mono">v{{ data.serverHealth }}</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-icon"
|
||||
type="button"
|
||||
title="刷新全部数据"
|
||||
:disabled="refreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<MxIcon name="refresh" :size="17" />
|
||||
</button>
|
||||
|
||||
<template v-if="auth.isGuest">
|
||||
<RouterLink class="mx-btn mx-btn-secondary mx-btn-sm" :to="{ name: 'login' }">
|
||||
<MxIcon name="user" :size="15" /> 登录
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="who">
|
||||
<span class="mx-avatar">{{ auth.displayName.slice(0, 1) }}</span>
|
||||
<span class="who-text">
|
||||
<strong>{{ auth.displayName }}</strong>
|
||||
<small>{{ auth.roleLabel }}</small>
|
||||
</span>
|
||||
</span>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-icon" type="button" title="退出登录" @click="logout">
|
||||
<MxIcon name="logout" :size="17" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-desktop-main">
|
||||
<div v-if="auth.readOnly" class="readonly-banner">
|
||||
<MxIcon name="warning" :size="16" />
|
||||
<span>当前为访客只读视图,录入与发布操作已停用。</span>
|
||||
<RouterLink class="mx-btn mx-btn-primary mx-btn-sm" :to="{ name: 'login' }">登录以录入成绩</RouterLink>
|
||||
</div>
|
||||
<div v-if="data.isMock" class="mock-banner">
|
||||
<span class="mx-badge mx-badge-warning">示例数据</span>
|
||||
<span>{{ data.mockLabel }},所有内容仅用于界面演示,不会写入真实赛事。</span>
|
||||
</div>
|
||||
<div v-if="data.error" class="mock-banner error-banner">
|
||||
<span class="mx-badge mx-badge-error">加载失败</span>
|
||||
<span>{{ data.error }}</span>
|
||||
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="refresh">重试</button>
|
||||
</div>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.menu-btn {
|
||||
display: none;
|
||||
}
|
||||
.brand {
|
||||
padding: 6px 10px 14px;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.nav-group + .nav-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.mx-nav-item.router-link-active {
|
||||
background: var(--mx-primary-glow);
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.side-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--mx-t3);
|
||||
background: var(--mx-s2);
|
||||
border-radius: var(--mx-r-sm);
|
||||
}
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* 窄屏下 vendor 不提供桌面顶栏样式,这里补一份,保证 1024 以下也能用。 */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--mx-divider);
|
||||
background: var(--mx-s1);
|
||||
}
|
||||
.mx-desktop-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px;
|
||||
}
|
||||
.version {
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.who-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.who-text strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.who-text small {
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.readonly-banner,
|
||||
.mock-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
.readonly-banner {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.mock-banner {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.error-banner {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.error-banner .mx-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.readonly-banner .mx-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
/* vendor 的 layout.css 在窄屏隐藏桌面壳,管理端必须始终显示主内容区。 */
|
||||
.mx-desktop-shell {
|
||||
display: flex !important;
|
||||
}
|
||||
.menu-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
.mx-sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 500;
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--mx-d2) var(--mx-ease);
|
||||
display: block;
|
||||
}
|
||||
.mx-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.who-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import '@/shared/styles/base.css'
|
||||
import './styles/miuix.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { formatPoints, type RecordEntry } from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const records = ref<RecordEntry[]>([])
|
||||
|
||||
const publishedCount = computed(() => data.events.filter((event) => event.status === 'published').length)
|
||||
const pendingEvents = computed(() => data.events.filter((event) => event.status === 'pending'))
|
||||
const topTeams = computed(() => data.standings.slice(0, 3))
|
||||
const leader = computed(() => data.standings[0] ?? null)
|
||||
|
||||
const connectionText = computed(() => {
|
||||
if (data.isMock) return '示例数据'
|
||||
if (data.realtimeStatus === 'online') return '实时在线'
|
||||
if (data.realtimeStatus === 'connecting') return '连接中'
|
||||
return '连接中断'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
records.value = await adminApi.records()
|
||||
} catch {
|
||||
records.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="仪表盘" :subtitle="data.settings?.event.name ?? '赛事概览'">
|
||||
<template #actions>
|
||||
<RouterLink class="mx-btn mx-btn-primary" :to="{ name: 'entry' }">
|
||||
<MxIcon name="entry" :size="16" /> 继续录入
|
||||
</RouterLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<section class="mx-stat-grid mx-section">
|
||||
<div class="mx-stat">
|
||||
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="standings" :size="14" /></span>领先单位</div>
|
||||
<div class="mx-stat-value">{{ leader?.shortName ?? leader?.teamName ?? '—' }}</div>
|
||||
<span class="mx-stat-delta up">{{ leader ? `${formatPoints(leader.totalPoints)} 分` : '暂无成绩' }}</span>
|
||||
</div>
|
||||
<div class="mx-stat">
|
||||
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="entry" :size="14" /></span>已发布项目</div>
|
||||
<div class="mx-stat-value">{{ publishedCount }}/{{ data.events.length }}</div>
|
||||
<span class="mx-stat-delta" :class="pendingEvents.length > 0 ? 'down' : 'up'">
|
||||
{{ pendingEvents.length }} 项待录入
|
||||
</span>
|
||||
</div>
|
||||
<div class="mx-stat">
|
||||
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="record" :size="14" /></span>纪录条目</div>
|
||||
<div class="mx-stat-value">{{ records.length }}</div>
|
||||
<span class="mx-stat-delta up">赛会纪录库</span>
|
||||
</div>
|
||||
<div class="mx-stat">
|
||||
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="display" :size="14" /></span>实时连接</div>
|
||||
<div class="mx-stat-value conn">{{ connectionText }}</div>
|
||||
<span class="mx-stat-delta up">
|
||||
{{ data.serverVersion === null ? '版本未知' : `版本 ${data.serverVersion}` }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="dash-grid">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">当前赛程</span>
|
||||
<span class="mx-badge mx-badge-neutral">{{ data.events.length }} 个小项</span>
|
||||
</div>
|
||||
<div class="mx-card-body no-pad">
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>组别</th>
|
||||
<th>状态</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in data.events.slice(0, 8)" :key="event.id">
|
||||
<td>{{ event.name }}</td>
|
||||
<td class="mono">{{ event.code }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="mx-badge"
|
||||
:class="event.status === 'published' ? 'mx-badge-success' : 'mx-badge-warning'"
|
||||
>
|
||||
{{ event.status === 'published' ? '已发布' : '待录入' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="right">
|
||||
<RouterLink class="mx-btn mx-btn-ghost mx-btn-sm" :to="{ name: 'entry', query: { eventId: event.id } }">
|
||||
录入
|
||||
</RouterLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="data.events.length === 0">
|
||||
<td colspan="4">
|
||||
<div class="mx-empty">
|
||||
<div class="mx-empty-title">还没有项目</div>
|
||||
<div class="mx-empty-desc">先在「基础数据」里创建小项</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">实时积分 Top 3</span>
|
||||
<RouterLink class="mx-btn mx-btn-ghost mx-btn-sm" :to="{ name: 'standings' }">查看全部</RouterLink>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<ol class="top-list">
|
||||
<li v-for="row in topTeams" :key="row.teamId" class="top-item">
|
||||
<span class="top-rank">{{ row.rank }}</span>
|
||||
<span class="top-name">
|
||||
<strong>{{ row.teamName }}</strong>
|
||||
<small>金 {{ row.gold }} · 银 {{ row.silver }} · 铜 {{ row.bronze }}</small>
|
||||
</span>
|
||||
<span class="top-points mono">{{ formatPoints(row.totalPoints) }}</span>
|
||||
</li>
|
||||
<li v-if="topTeams.length === 0" class="top-empty">暂无已发布成绩</li>
|
||||
</ol>
|
||||
|
||||
<div v-if="pendingEvents.length > 0" class="todo">
|
||||
<div class="todo-title"><MxIcon name="warning" :size="14" /> 待办</div>
|
||||
<p>
|
||||
还有 {{ pendingEvents.length }} 个项目未录入:
|
||||
{{ pendingEvents.map((event) => event.name).join('、') }}
|
||||
</p>
|
||||
<RouterLink class="mx-btn mx-btn-primary mx-btn-sm" :to="{ name: 'entry' }">去录入</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-if="!auth.readOnly" class="foot-hint">
|
||||
提示:录入页支持 <kbd>Tab</kbd> / <kbd>Enter</kbd> 在格子间移动,<kbd>Ctrl</kbd>+<kbd>Enter</kbd> 存草稿,
|
||||
<kbd>Alt</kbd>+<kbd>Enter</kbd> 发布。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conn {
|
||||
font-size: 20px;
|
||||
}
|
||||
.no-pad {
|
||||
padding: 0;
|
||||
}
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.top-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.top-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.top-rank {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
background: var(--mx-primary-glow);
|
||||
color: var(--mx-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.top-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.top-name small {
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.top-points {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.top-empty {
|
||||
color: var(--mx-t3);
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.todo {
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.todo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--mx-amber);
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.todo p {
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.foot-hint {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
kbd {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--mx-border);
|
||||
border-radius: 5px;
|
||||
background: var(--mx-s3);
|
||||
font-family: var(--mx-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.dash-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDateTime } from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
interface ScreenNode {
|
||||
key: string
|
||||
name: string
|
||||
online: boolean | null
|
||||
lastSeen: string | null
|
||||
}
|
||||
|
||||
const PAGES: Array<{ key: string; label: string }> = [
|
||||
{ key: 'standings', label: '总积分榜' },
|
||||
{ key: 'medals', label: '团体奖牌榜' },
|
||||
{ key: 'athletes', label: '个人积分榜' },
|
||||
{ key: 'latest', label: '最新成绩' },
|
||||
]
|
||||
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const currentPage = ref('standings')
|
||||
const locked = ref(false)
|
||||
const announceText = ref('')
|
||||
const announceSeconds = ref(15)
|
||||
const pending = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 在线看板。
|
||||
*
|
||||
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
|
||||
* 因此这里只登记本地已知的屏,并用「最近一次命令是否送达」推断在线状态。
|
||||
* 后端补上接口后,把 screens 换成接口数据即可。
|
||||
*/
|
||||
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastSeen: null }])
|
||||
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
|
||||
|
||||
function markSeen(): void {
|
||||
screens.value = screens.value.map((screen) => ({
|
||||
...screen,
|
||||
online: true,
|
||||
lastSeen: new Date().toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> {
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
pending.value = action
|
||||
try {
|
||||
if (action === 'goto') {
|
||||
await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
|
||||
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
|
||||
} else if (action === 'lock' || action === 'unlock') {
|
||||
await adminApi.displayCommand({ action, payload: {} })
|
||||
locked.value = action === 'lock'
|
||||
toast.success(action === 'lock' ? '大屏已锁定,停止自动轮播' : '大屏已解锁,恢复轮播')
|
||||
} else if (action === 'reload') {
|
||||
await adminApi.displayCommand({ action, payload: {} })
|
||||
toast.success('已请求所有大屏重新拉取快照')
|
||||
} else {
|
||||
const text = announceText.value.trim()
|
||||
if (text === '') {
|
||||
toast.error('公告内容不能为空')
|
||||
return
|
||||
}
|
||||
await adminApi.displayCommand({ action, payload: { text, seconds: announceSeconds.value } })
|
||||
announceText.value = ''
|
||||
toast.success('公告已推送')
|
||||
}
|
||||
markSeen()
|
||||
} catch (caught) {
|
||||
toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="大屏控制" subtitle="切页 / 锁定 / 刷新 / 公告 / 在线看板">
|
||||
<template #actions>
|
||||
<span class="mx-badge" :class="locked ? 'mx-badge-warning' : 'mx-badge-success'">
|
||||
{{ locked ? '已锁定' : '轮播中' }}
|
||||
</span>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="display-grid">
|
||||
<div class="col">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head"><span class="mx-card-title">切页</span></div>
|
||||
<div class="mx-card-body">
|
||||
<div class="page-grid">
|
||||
<button
|
||||
v-for="page in PAGES"
|
||||
:key="page.key"
|
||||
class="page-btn"
|
||||
:class="{ active: currentPage === page.key }"
|
||||
type="button"
|
||||
@click="currentPage = page.key"
|
||||
>
|
||||
<MxIcon name="display" :size="16" />
|
||||
{{ page.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null"
|
||||
@click="send('goto')"
|
||||
>
|
||||
<MxIcon name="push" :size="15" />
|
||||
{{ pending === 'goto' ? '下发中…' : '立即跳转' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head"><span class="mx-card-title">锁定与刷新</span></div>
|
||||
<div class="mx-card-body">
|
||||
<div class="action-row">
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null || locked"
|
||||
@click="send('lock')"
|
||||
>
|
||||
<MxIcon name="lock" :size="15" /> 锁定
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null || !locked"
|
||||
@click="send('unlock')"
|
||||
>
|
||||
<MxIcon name="unlock" :size="15" /> 解锁
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null"
|
||||
@click="send('reload')"
|
||||
>
|
||||
<MxIcon name="refresh" :size="15" /> 强制刷新
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">锁定后停止自动轮播并停在当前页;强制刷新让所有大屏重新拉取快照,用于排障。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head"><span class="mx-card-title">公告</span></div>
|
||||
<div class="mx-card-body">
|
||||
<div class="mx-input-wrap textarea-wrap">
|
||||
<textarea
|
||||
v-model="announceText"
|
||||
class="mx-input announce"
|
||||
rows="3"
|
||||
placeholder="输入要在大屏底部滚动显示的公告"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="announce-foot">
|
||||
<label class="seconds">
|
||||
滚动时长
|
||||
<input v-model.number="announceSeconds" class="mx-cell-input seconds-input" type="number" min="5" max="120" />
|
||||
秒
|
||||
</label>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="!canWrite || pending !== null"
|
||||
@click="send('announce')"
|
||||
>
|
||||
<MxIcon name="bell" :size="15" />
|
||||
{{ pending === 'announce' ? '推送中…' : '推送公告' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">在线看板</span>
|
||||
<span class="mx-badge" :class="onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||
{{ onlineCount }} / {{ screens.length }} 在线
|
||||
</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div class="board-note">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>后端未提供大屏在线接口(devdocs/05 §7 未定义),下表为本地登记节点,在线状态按最近一次指令送达推断。</span>
|
||||
</div>
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>大屏</th>
|
||||
<th>状态</th>
|
||||
<th>最后心跳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="screen in screens" :key="screen.key">
|
||||
<td>{{ screen.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="mx-badge"
|
||||
:class="screen.online === true ? 'mx-badge-success' : screen.online === false ? 'mx-badge-error' : 'mx-badge-neutral'"
|
||||
>
|
||||
{{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mono muted">{{ screen.lastSeen === null ? '—' : formatDateTime(screen.lastSeen) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="board-hint">
|
||||
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ currentPage }}</p>
|
||||
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.display-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.page-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
border: 1px solid transparent;
|
||||
color: var(--mx-t2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--mx-d2) var(--mx-ease);
|
||||
}
|
||||
.page-btn:hover {
|
||||
color: var(--mx-text);
|
||||
border-color: var(--mx-border);
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--mx-primary-glow);
|
||||
border-color: var(--mx-primary);
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hint,
|
||||
.board-note,
|
||||
.board-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.textarea-wrap {
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.announce {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.announce-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seconds {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.seconds-input {
|
||||
width: 68px;
|
||||
text-align: center;
|
||||
}
|
||||
.board-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
color: var(--mx-amber);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.board-hint {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.board-hint .mock {
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.display-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import { mockMode, mockReason } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const data = useEventStore()
|
||||
const toast = useToastStore()
|
||||
const router = useRouter()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const canSubmit = computed(() => username.value.trim() !== '' && password.value !== '' && !auth.loading)
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSubmit.value) return
|
||||
const ok = await auth.login(username.value.trim(), password.value)
|
||||
if (!ok) {
|
||||
toast.error(auth.error ?? '登录失败')
|
||||
return
|
||||
}
|
||||
toast.success(`欢迎回来,${auth.displayName}`)
|
||||
await data.load()
|
||||
void router.push({ name: 'entry' })
|
||||
}
|
||||
|
||||
function asGuest(): void {
|
||||
toast.info('已进入只读访客视图')
|
||||
void router.push({ name: 'standings' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="mx-card login-card">
|
||||
<div class="mx-card-body">
|
||||
<div class="login-brand">
|
||||
<span class="mx-logo-icon"><MxIcon name="standings" :size="20" /></span>
|
||||
<div>
|
||||
<h1>积分榜管理面板</h1>
|
||||
<p>2026 职工运动会 · 成绩录入与实时积分</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="login-form" @submit.prevent="submit">
|
||||
<label class="mx-label" for="username">账号</label>
|
||||
<div class="mx-input-wrap">
|
||||
<span class="mx-input-icon"><MxIcon name="user" :size="16" /></span>
|
||||
<input id="username" v-model="username" class="mx-input" autocomplete="username" placeholder="录入员 / 裁判长" />
|
||||
</div>
|
||||
|
||||
<label class="mx-label" for="password">密码</label>
|
||||
<div class="mx-input-wrap">
|
||||
<span class="mx-input-icon"><MxIcon name="lock" :size="16" /></span>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
class="mx-input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="mx-btn mx-btn-primary mx-btn-lg login-submit" type="submit" :disabled="!canSubmit">
|
||||
<MxIcon name="check" :size="16" />
|
||||
{{ auth.loading ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-divider"><span>或</span></div>
|
||||
|
||||
<button class="mx-btn mx-btn-secondary login-submit" type="button" @click="asGuest">
|
||||
以访客身份只读浏览
|
||||
</button>
|
||||
|
||||
<p v-if="mockMode" class="mx-badge mx-badge-warning login-mock">
|
||||
示例数据:{{ mockReason ?? '服务端未就绪' }},登录后操作不会写入真实赛事
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 80% -10%, var(--mx-primary-glow), transparent 60%),
|
||||
var(--mx-bg);
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
.login-brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-brand h1 {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.login-brand p {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.login-form .mx-label:not(:first-child) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 18px 0;
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--mx-divider);
|
||||
}
|
||||
.login-mock {
|
||||
margin-top: 18px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import type { IconName } from '../components/icons'
|
||||
|
||||
/**
|
||||
* 未实现页面的统一骨架。
|
||||
* M4 只要求占位路由,这里把「这页将来做什么」写清楚,避免点进来是一片空白。
|
||||
*/
|
||||
const route = useRoute()
|
||||
|
||||
const TITLE_ICONS: Record<string, IconName> = {
|
||||
review: 'review',
|
||||
records: 'record',
|
||||
adjustments: 'adjust',
|
||||
'base-data': 'database',
|
||||
export: 'export',
|
||||
history: 'history',
|
||||
logs: 'logs',
|
||||
system: 'system',
|
||||
}
|
||||
|
||||
const title = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '页面'))
|
||||
const note = computed(() => (typeof route.meta.note === 'string' ? route.meta.note : ''))
|
||||
const icon = computed<IconName>(() => TITLE_ICONS[String(route.name)] ?? 'system')
|
||||
|
||||
const PLANNED: Record<string, string[]> = {
|
||||
review: ['草稿列表(按项目 / 时间筛选)', '并排对比历史版本', '批量发布 / 退回,留审计'],
|
||||
records: ['疑似破纪录自动提示', '裁判长确认后生成加分流水', '本期破纪录加分关闭,功能保留'],
|
||||
adjustments: ['人工加减分(分值可正可负)', '类别:处罚 / 补分 / 更正', '理由必填、可作废、全程审计'],
|
||||
'base-data': ['单位 / 大项 / 小项 / 组别', '积分模板与计分通道', '运动员与报名表 xlsx 导入'],
|
||||
export: ['总表 / 分项成绩表 / 单位明细', '按小项、组别单独导出', '赛事项目包 zip 导入导出'],
|
||||
history: ['仿 Git Graph 的时间轴 + 分支线', '节点详情:操作人 / 动作 / 分数增减', '只读,不做一键回滚'],
|
||||
logs: ['审计日志:谁在什么时候改了什么', '按实体类型 / 用户 / 时间筛选', '可关联 requestId 追查重试'],
|
||||
system: ['用户与角色管理', '备份与恢复', '赛事信息与项目切换'],
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader :title="title" :subtitle="note">
|
||||
<template #actions>
|
||||
<span class="mx-badge mx-badge-neutral">规划中</span>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mx-card">
|
||||
<div class="mx-card-body">
|
||||
<div class="skeleton-head">
|
||||
<span class="skeleton-icon"><MxIcon :name="icon" :size="22" /></span>
|
||||
<div>
|
||||
<h2>{{ title }}页面将在后续里程碑实现</h2>
|
||||
<p>M4 只交付占位路由与骨架,核心链路(成绩录入 → 积分榜 → 大屏)已可用。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="planned">
|
||||
<div class="planned-title">计划能力</div>
|
||||
<ul>
|
||||
<li v-for="item in PLANNED[String(route.name)] ?? []" :key="item">
|
||||
<MxIcon name="check" :size="14" />
|
||||
<span>{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mx-stat-grid skeleton-grid">
|
||||
<div v-for="index in 3" :key="index" class="mx-stat skeleton-stat">
|
||||
<div class="mx-skeleton line short"></div>
|
||||
<div class="mx-skeleton line tall"></div>
|
||||
<div class="mx-skeleton line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.skeleton-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-primary-glow);
|
||||
color: var(--mx-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skeleton-head h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.skeleton-head p {
|
||||
font-size: 13px;
|
||||
color: var(--mx-t3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.planned {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.planned-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mx-t3);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.planned ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.planned li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.planned li svg {
|
||||
color: var(--mx-green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skeleton-grid {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.skeleton-stat {
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.line {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.line.short {
|
||||
width: 40%;
|
||||
}
|
||||
.line.tall {
|
||||
height: 24px;
|
||||
width: 60%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,465 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDateTime, formatPoints, formatRankChange, type TeamBreakdown } 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'
|
||||
|
||||
type TabKey = 'points' | 'medals' | 'athletes'
|
||||
|
||||
const TABS: Array<{ key: TabKey; label: string }> = [
|
||||
{ key: 'points', label: '单位总分榜' },
|
||||
{ key: 'medals', label: '团体奖牌榜' },
|
||||
{ key: 'athletes', label: '个人积分榜' },
|
||||
]
|
||||
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const activeTab = ref<TabKey>('points')
|
||||
const expandedTeamId = ref<number | null>(null)
|
||||
const breakdowns = ref<Record<number, TeamBreakdown>>({})
|
||||
const loadingBreakdown = ref(false)
|
||||
const pushing = ref(false)
|
||||
const closeDialog = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
|
||||
const filteredStandings = computed(() => {
|
||||
const keyword = search.value.trim()
|
||||
if (keyword === '') return data.standings
|
||||
return data.standings.filter(
|
||||
(row) => row.teamName.includes(keyword) || (row.shortName ?? '').includes(keyword) || row.teamCode.includes(keyword),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredAthletes = computed(() => {
|
||||
const keyword = search.value.trim()
|
||||
if (keyword === '') return data.athleteStandings
|
||||
return data.athleteStandings.filter(
|
||||
(row) => row.athleteName.includes(keyword) || row.teamName.includes(keyword),
|
||||
)
|
||||
})
|
||||
|
||||
const activeBreakdown = computed<TeamBreakdown | null>(() =>
|
||||
expandedTeamId.value === null ? null : (breakdowns.value[expandedTeamId.value] ?? null),
|
||||
)
|
||||
|
||||
async function toggleTeam(teamId: number): Promise<void> {
|
||||
if (expandedTeamId.value === teamId) {
|
||||
expandedTeamId.value = null
|
||||
return
|
||||
}
|
||||
expandedTeamId.value = teamId
|
||||
if (breakdowns.value[teamId] !== undefined) return
|
||||
loadingBreakdown.value = true
|
||||
try {
|
||||
breakdowns.value = { ...breakdowns.value, [teamId]: await adminApi.breakdown(teamId) }
|
||||
} catch (caught) {
|
||||
toast.error('加载积分明细失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
loadingBreakdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToDisplay(): Promise<void> {
|
||||
const detail = activeBreakdown.value
|
||||
if (detail === null) return
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可推送', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
try {
|
||||
await adminApi.displayCommand({
|
||||
action: 'show-detail',
|
||||
payload: {
|
||||
teamId: detail.teamId,
|
||||
title: `${detail.teamName} 积分明细`,
|
||||
detail,
|
||||
autoCloseSeconds: 60,
|
||||
},
|
||||
})
|
||||
toast.success('已推送至大屏', '60 秒后自动关闭')
|
||||
} catch (caught) {
|
||||
toast.error('推送失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDisplayDetail(): Promise<void> {
|
||||
pushing.value = true
|
||||
try {
|
||||
await adminApi.displayCommand({ action: 'hide-detail', payload: {} })
|
||||
closeDialog.value = false
|
||||
toast.info('已关闭大屏明细覆盖层')
|
||||
} catch (caught) {
|
||||
toast.error('关闭失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
await data.refreshStandings()
|
||||
toast.success('积分榜已刷新')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="积分榜" subtitle="实时排序,不做固定顺序;点击单位行可下钻明细">
|
||||
<template #actions>
|
||||
<button class="mx-btn mx-btn-secondary" type="button" @click="refresh">
|
||||
<MxIcon name="refresh" :size="15" /> 刷新
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-ghost" type="button" @click="closeDialog = true">
|
||||
<MxIcon name="close" :size="15" /> 关闭大屏明细
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="standings-toolbar">
|
||||
<div class="mx-tabs">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.key"
|
||||
class="mx-tab"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
type="button"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</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 class="standings-grid">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">{{ TABS.find((tab) => tab.key === activeTab)?.label }}</span>
|
||||
<span class="mx-badge mx-badge-info">
|
||||
更新于 {{ formatDateTime(data.lastRefreshAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
<!-- 单位总分榜 -->
|
||||
<table v-if="activeTab === 'points'" class="mx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名次</th>
|
||||
<th>单位</th>
|
||||
<th class="right">总分</th>
|
||||
<th class="center">金</th>
|
||||
<th class="center">银</th>
|
||||
<th class="center">铜</th>
|
||||
<th class="center">变化</th>
|
||||
<th class="right">明细</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredStandings" :key="row.teamId" :class="{ expanded: expandedTeamId === row.teamId }">
|
||||
<td>
|
||||
<span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="team-name">{{ row.teamName }}</span>
|
||||
<small class="team-code mono">{{ row.teamCode }}</small>
|
||||
</td>
|
||||
<td class="right points mono">{{ formatPoints(row.totalPoints) }}</td>
|
||||
<td class="center mono gold">{{ row.gold }}</td>
|
||||
<td class="center mono silver">{{ row.silver }}</td>
|
||||
<td class="center mono bronze">{{ row.bronze }}</td>
|
||||
<td class="center change">{{ formatRankChange(row.rankChange) || '—' }}</td>
|
||||
<td class="right">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="toggleTeam(row.teamId)">
|
||||
{{ expandedTeamId === row.teamId ? '收起' : '展开' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredStandings.length === 0">
|
||||
<td colspan="8">
|
||||
<div class="mx-empty">
|
||||
<div class="mx-empty-title">暂无积分数据</div>
|
||||
<div class="mx-empty-desc">发布成绩后这里会实时更新</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 团体奖牌榜 -->
|
||||
<table v-else-if="activeTab === 'medals'" class="mx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名次</th>
|
||||
<th>单位</th>
|
||||
<th class="center">金</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 filteredStandings" :key="row.teamId">
|
||||
<td><span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span></td>
|
||||
<td>{{ row.teamName }}</td>
|
||||
<td class="center mono gold">{{ row.gold }}</td>
|
||||
<td class="center mono silver">{{ row.silver }}</td>
|
||||
<td class="center mono bronze">{{ row.bronze }}</td>
|
||||
<td class="right mono">{{ row.medalTotal }}</td>
|
||||
<td class="right">
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="toggleTeam(row.teamId)">
|
||||
{{ expandedTeamId === row.teamId ? '收起' : '展开' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 个人积分榜 -->
|
||||
<table v-else class="mx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名次</th>
|
||||
<th>运动员</th>
|
||||
<th>单位</th>
|
||||
<th class="right">个人总分</th>
|
||||
<th class="center">金</th>
|
||||
<th class="center">银</th>
|
||||
<th class="center">铜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredAthletes" :key="`${row.athleteId}-${row.teamId}`">
|
||||
<td><span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span></td>
|
||||
<td>{{ row.athleteName }}</td>
|
||||
<td>{{ row.teamName }}</td>
|
||||
<td class="right points mono">{{ formatPoints(row.totalPoints) }}</td>
|
||||
<td class="center mono gold">{{ row.gold }}</td>
|
||||
<td class="center mono silver">{{ row.silver }}</td>
|
||||
<td class="center mono bronze">{{ row.bronze }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredAthletes.length === 0">
|
||||
<td colspan="7">
|
||||
<div class="mx-empty">
|
||||
<div class="mx-empty-title">暂无个人积分</div>
|
||||
<div class="mx-empty-desc">个人项目发布后自动汇总</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="mx-card detail-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">
|
||||
{{ activeBreakdown === null ? '单位积分明细' : `${activeBreakdown.teamName} · 明细` }}
|
||||
</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="activeBreakdown === null || !canWrite || pushing"
|
||||
@click="pushToDisplay"
|
||||
>
|
||||
<MxIcon name="push" :size="14" /> 推送到大屏
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div v-if="activeBreakdown === null" class="mx-empty">
|
||||
<div class="mx-empty-icon"><MxIcon name="standings" :size="26" /></div>
|
||||
<div class="mx-empty-title">选择左侧单位查看明细</div>
|
||||
<div class="mx-empty-desc">每一分都可溯源到具体项目与名次</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="detail-total">
|
||||
<span>合计</span>
|
||||
<strong class="mono">{{ formatPoints(activeBreakdown.totalPoints) }}</strong>
|
||||
</div>
|
||||
<ul class="detail-list">
|
||||
<li v-for="item in activeBreakdown.items" :key="item.ledgerId" class="detail-item">
|
||||
<span class="detail-main">
|
||||
<strong>{{ item.eventName ?? '—' }}</strong>
|
||||
<small>{{ item.categoryName ?? '' }} · {{ item.athleteName ?? '团体' }}</small>
|
||||
</span>
|
||||
<span class="detail-result mono">{{ item.resultText ?? '—' }}</span>
|
||||
<span class="detail-rank mono">第 {{ item.rank ?? '—' }} 名</span>
|
||||
<span class="detail-points mono">+{{ formatPoints(item.points) }}</span>
|
||||
</li>
|
||||
<li v-if="activeBreakdown.items.length === 0" class="detail-empty">暂无已发布的得分记录</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="closeDialog"
|
||||
tone="warning"
|
||||
title="关闭大屏积分明细?"
|
||||
message="大屏将关闭当前的半屏明细覆盖层,并恢复自动轮播。"
|
||||
confirm-text="关闭明细"
|
||||
:pending="pushing"
|
||||
@confirm="closeDisplayDetail"
|
||||
@cancel="closeDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.standings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search {
|
||||
width: 260px;
|
||||
}
|
||||
.standings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--mx-s3);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank-badge.top {
|
||||
background: var(--mx-primary-glow);
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.team-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.team-code {
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.points {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.gold {
|
||||
color: #ffd75e;
|
||||
}
|
||||
.silver {
|
||||
color: #cfd6e4;
|
||||
}
|
||||
.bronze {
|
||||
color: #d98b4a;
|
||||
}
|
||||
.change {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
tr.expanded {
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
.detail-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.detail-total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.detail-total strong {
|
||||
font-size: 22px;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 52dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.detail-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-main strong {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-main small {
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.detail-result,
|
||||
.detail-rank {
|
||||
color: var(--mx-t2);
|
||||
font-size: 11px;
|
||||
}
|
||||
.detail-points {
|
||||
color: var(--mx-green);
|
||||
font-weight: 800;
|
||||
}
|
||||
.detail-empty {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
.standings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.detail-card {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import AdminLayout from './layouts/AdminLayout.vue'
|
||||
|
||||
/**
|
||||
* 路由表。占位页统一走 PlaceholderPage,避免为每个未实现页面各写一个空组件。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('./pages/LoginPage.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{ path: '', redirect: { name: 'dashboard' } },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('./pages/DashboardPage.vue'),
|
||||
meta: { title: '仪表盘' },
|
||||
},
|
||||
{
|
||||
path: 'entry',
|
||||
name: 'entry',
|
||||
component: () => import('./pages/EntryPage.vue'),
|
||||
meta: { title: '成绩录入' },
|
||||
},
|
||||
{
|
||||
path: 'review',
|
||||
name: 'review',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '成绩审核', note: '草稿列表 → 对比 → 发布 / 退回' },
|
||||
},
|
||||
{
|
||||
path: 'records',
|
||||
name: 'records',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '破纪录', note: '疑似破纪录列表 → 确认 → 加分' },
|
||||
},
|
||||
{
|
||||
path: 'adjustments',
|
||||
name: 'adjustments',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '积分调整', note: '人工加减分(必填理由),本期规则关闭' },
|
||||
},
|
||||
{
|
||||
path: 'standings',
|
||||
name: 'standings',
|
||||
component: () => import('./pages/StandingsPage.vue'),
|
||||
meta: { title: '积分榜' },
|
||||
},
|
||||
{
|
||||
path: 'display',
|
||||
name: 'display',
|
||||
component: () => import('./pages/DisplayPage.vue'),
|
||||
meta: { title: '大屏控制' },
|
||||
},
|
||||
{
|
||||
path: 'base-data',
|
||||
name: 'base-data',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '基础数据', note: '单位 / 大项 / 小项 / 积分模板 / 运动员 / 报名导入' },
|
||||
},
|
||||
{
|
||||
path: 'export',
|
||||
name: 'export',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '导出', note: '积分榜 / 明细 / 成绩册 / 赛事项目包' },
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
name: 'history',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '变更历史', note: '仿 Git Graph 的操作图谱(只读)' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '日志', note: '审计日志:谁在什么时候改了什么' },
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'system',
|
||||
component: () => import('./pages/PlaceholderPage.vue'),
|
||||
meta: { title: '系统', note: '用户管理 / 备份恢复 / 赛事信息' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } },
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
// 服务端只在 /admin 精确路径返回 admin.html(见 httpapi/server.go 静态路由),
|
||||
// 用 hash 模式才能让刷新 / 深链(#/entry?eventId=101)不落到 404。
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = typeof to.meta.title === 'string' ? to.meta.title : ''
|
||||
document.title = title === '' ? '管理面板' : `${title} · 管理面板`
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CurrentUser } from '@/shared'
|
||||
import { adminApi, mockMode, setUnauthorizedHandler } from '../api/client'
|
||||
|
||||
/**
|
||||
* 会话状态。
|
||||
*
|
||||
* 未登录时不是「拦截」,而是降级为 guest 只读视图(07 第 1 节):
|
||||
* 现场录入员换班、或服务端会话过期时,管理员仍能查看积分与成绩。
|
||||
*/
|
||||
export const useAuthStore = defineStore('admin-auth', () => {
|
||||
const user = ref<CurrentUser | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
|
||||
const initialized = ref(false)
|
||||
|
||||
const isGuest = computed(() => user.value === null)
|
||||
const readOnly = computed(() => user.value === null)
|
||||
const displayName = computed(() => user.value?.displayName ?? '访客')
|
||||
const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户')))
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (initialized.value) return
|
||||
initialized.value = true
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
user.value = await adminApi.me()
|
||||
} catch {
|
||||
// 未登录 / 后端未就绪都走只读访客,不把用户挡在门外。
|
||||
user.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string): Promise<boolean> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
user.value = await adminApi.login(username, password)
|
||||
initialized.value = true
|
||||
return true
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '登录失败'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await adminApi.logout()
|
||||
} catch {
|
||||
// 登出失败也要清本地态,否则用户会卡在已登录界面。
|
||||
}
|
||||
user.value = null
|
||||
}
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
user.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isGuest,
|
||||
readOnly,
|
||||
displayName,
|
||||
roleLabel,
|
||||
mockMode,
|
||||
load,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RealtimeClient, type RealtimeStatus, type SnapshotMessage } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
Event,
|
||||
EventSettings,
|
||||
ScoringProfile,
|
||||
Sport,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import { adminApi, mockMode, mockReason } from '../api/client'
|
||||
|
||||
/**
|
||||
* 赛事全局数据(单位 / 项目 / 积分榜)。
|
||||
*
|
||||
* 管理端断网时必须禁止写操作(05 第 4 节),所以连接状态放在这里,
|
||||
* 各页面统一用 `canWrite` 判断按钮是否可点。
|
||||
*/
|
||||
export const useEventStore = defineStore('admin-event', () => {
|
||||
const settings = ref<EventSettings | null>(null)
|
||||
const teams = ref<Team[]>([])
|
||||
const sports = ref<Sport[]>([])
|
||||
const events = ref<Event[]>([])
|
||||
const profiles = ref<ScoringProfile[]>([])
|
||||
const standings = ref<StandingRow[]>([])
|
||||
const athleteStandings = ref<AthleteStanding[]>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const lastRefreshAt = ref<string | null>(null)
|
||||
|
||||
const realtimeStatus = ref<RealtimeStatus>('idle')
|
||||
const serverVersion = ref<number | null>(null)
|
||||
const serverTime = ref<string | null>(null)
|
||||
const serverHealth = ref<string | null>(null)
|
||||
|
||||
let realtime: RealtimeClient | null = null
|
||||
|
||||
const isMock = computed(() => mockMode.value)
|
||||
const mockLabel = computed(() =>
|
||||
mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null,
|
||||
)
|
||||
const online = computed(() => realtimeStatus.value === 'online')
|
||||
/**
|
||||
* 写操作门槛:真实后端需实时在线;示例数据模式允许本地写入以便走通交互。
|
||||
* 登录态由 auth store 叠加判断,这里只管连接。
|
||||
*/
|
||||
const canWrite = computed(() => mockMode.value || realtimeStatus.value === 'online')
|
||||
|
||||
function applySnapshot(snapshot: SnapshotMessage): void {
|
||||
settings.value = snapshot.settings
|
||||
teams.value = snapshot.teams
|
||||
events.value = snapshot.events
|
||||
standings.value = snapshot.standings
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await adminApi.bootstrap()
|
||||
settings.value = data.settings
|
||||
teams.value = data.teams
|
||||
sports.value = data.sports
|
||||
events.value = data.events
|
||||
profiles.value = data.profiles
|
||||
standings.value = data.standings
|
||||
athleteStandings.value = data.athleteStandings
|
||||
loaded.value = true
|
||||
lastRefreshAt.value = new Date().toISOString()
|
||||
if (!mockMode.value) startRealtime()
|
||||
void checkHealth()
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth(): Promise<void> {
|
||||
try {
|
||||
const info = await adminApi.health()
|
||||
serverHealth.value = info.version
|
||||
} catch {
|
||||
serverHealth.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startRealtime(): void {
|
||||
if (realtime !== null) return
|
||||
realtime = new RealtimeClient({
|
||||
role: 'admin',
|
||||
onSnapshot: applySnapshot,
|
||||
onChange: () => {
|
||||
// 增量只用于触发重拉,管理端不自己合并积分榜,避免与服务端算出的真值分叉。
|
||||
void refreshStandings()
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
realtimeStatus.value = status
|
||||
},
|
||||
onVersionChange: (version) => {
|
||||
serverVersion.value = version
|
||||
},
|
||||
onError: () => {
|
||||
// 连接错误由 onStatusChange 统一呈现,这里不弹 toast,避免刷屏。
|
||||
},
|
||||
})
|
||||
realtime.connect()
|
||||
}
|
||||
|
||||
function stopRealtime(): void {
|
||||
realtime?.close()
|
||||
realtime = null
|
||||
realtimeStatus.value = 'idle'
|
||||
}
|
||||
|
||||
async function refreshStandings(): Promise<void> {
|
||||
try {
|
||||
const [next, athletes] = await Promise.all([
|
||||
adminApi.standings(),
|
||||
adminApi.athleteStandings(),
|
||||
])
|
||||
standings.value = next
|
||||
athleteStandings.value = athletes
|
||||
lastRefreshAt.value = new Date().toISOString()
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '刷新积分榜失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEvents(): Promise<void> {
|
||||
try {
|
||||
events.value = await adminApi.client.events.list()
|
||||
} catch {
|
||||
// 项目列表刷新失败保留旧数据,页面仍有内容可操作。
|
||||
}
|
||||
}
|
||||
|
||||
function eventById(id: number): Event | undefined {
|
||||
return events.value.find((item) => item.id === id)
|
||||
}
|
||||
|
||||
function teamById(id: number): Team | undefined {
|
||||
return teams.value.find((item) => item.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
teams,
|
||||
sports,
|
||||
events,
|
||||
profiles,
|
||||
standings,
|
||||
athleteStandings,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
lastRefreshAt,
|
||||
realtimeStatus,
|
||||
serverVersion,
|
||||
serverTime,
|
||||
serverHealth,
|
||||
isMock,
|
||||
mockLabel,
|
||||
online,
|
||||
canWrite,
|
||||
load,
|
||||
checkHealth,
|
||||
refreshStandings,
|
||||
refreshEvents,
|
||||
eventById,
|
||||
teamById,
|
||||
stopRealtime,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastKind = 'success' | 'warning' | 'error' | 'info'
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export const useToastStore = defineStore('admin-toast', () => {
|
||||
const items = ref<ToastItem[]>([])
|
||||
|
||||
function dismiss(id: number): void {
|
||||
items.value = items.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
function push(kind: ToastKind, message: string, detail: string | null = null, ttlMs = 5000): number {
|
||||
const id = nextId++
|
||||
items.value = [...items.value, { id, kind, message, detail }]
|
||||
if (ttlMs > 0) window.setTimeout(() => dismiss(id), ttlMs)
|
||||
return id
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
dismiss,
|
||||
push,
|
||||
success: (message: string, detail: string | null = null) => push('success', message, detail),
|
||||
warning: (message: string, detail: string | null = null) => push('warning', message, detail),
|
||||
error: (message: string, detail: string | null = null) => push('error', message, detail, 8000),
|
||||
info: (message: string, detail: string | null = null) => push('info', message, detail),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* MIUIx 样式入口(管理端专用)。
|
||||
*
|
||||
* vendor/miuix-console 是现场离线环境的唯一来源,禁止 CDN。
|
||||
* 这里按「tokens → base → 组件」的顺序引入,保证组件里的 var() 都能解析。
|
||||
*/
|
||||
@import '../../../../vendor/miuix-console/core/tokens.css';
|
||||
@import '../../../../vendor/miuix-console/core/base.css';
|
||||
@import '../../../../vendor/miuix-console/core/animations.css';
|
||||
@import '../../../../vendor/miuix-console/components/layout.css';
|
||||
@import '../../../../vendor/miuix-console/components/navigation.css';
|
||||
@import '../../../../vendor/miuix-console/components/button.css';
|
||||
@import '../../../../vendor/miuix-console/components/card.css';
|
||||
@import '../../../../vendor/miuix-console/components/input.css';
|
||||
@import '../../../../vendor/miuix-console/components/select.css';
|
||||
@import '../../../../vendor/miuix-console/components/table.css';
|
||||
@import '../../../../vendor/miuix-console/components/badge.css';
|
||||
@import '../../../../vendor/miuix-console/components/tag.css';
|
||||
@import '../../../../vendor/miuix-console/components/tabs.css';
|
||||
@import '../../../../vendor/miuix-console/components/toast.css';
|
||||
@import '../../../../vendor/miuix-console/components/modal.css';
|
||||
@import '../../../../vendor/miuix-console/components/stat.css';
|
||||
@import '../../../../vendor/miuix-console/components/progress.css';
|
||||
@import '../../../../vendor/miuix-console/components/switch.css';
|
||||
@import '../../../../vendor/miuix-console/components/empty.css';
|
||||
@import '../../../../vendor/miuix-console/components/tooltip.css';
|
||||
@import '../../../../vendor/miuix-console/components/pagination.css';
|
||||
@import '../../../../vendor/miuix-console/components/checkbox.css';
|
||||
@import '../../../../vendor/miuix-console/components/drawer.css';
|
||||
@import '../../../../vendor/miuix-console/components/popover.css';
|
||||
@import '../../../../vendor/miuix-console/components/avatar.css';
|
||||
@import '../../../../vendor/miuix-console/components/accordion.css';
|
||||
@import '../../../../vendor/miuix-console/components/activity.css';
|
||||
|
||||
/*
|
||||
* 上游 tokens.css 只定义了 --mx-error,但 button/input/badge 等组件直接引用
|
||||
* --mx-red / --mx-accent。不改 vendor 文件(保持可整目录覆盖升级),在这里补齐别名。
|
||||
*/
|
||||
:root,
|
||||
[data-theme='dark'] {
|
||||
--mx-red: var(--mx-error);
|
||||
--mx-accent: var(--mx-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* 管理端不需要 100dvh 的自适应 App Shell,页面滚动交给 .mx-desktop-main,
|
||||
* 这里只保证 body 底色与 MIUIx 令牌一致(shared/base.css 的深蓝底不适用管理端)。
|
||||
*/
|
||||
body {
|
||||
background: var(--mx-bg);
|
||||
color: var(--mx-text);
|
||||
font-family: var(--mx-font);
|
||||
}
|
||||
|
||||
/* 表格内嵌输入框:用 MIUIx 的输入外观,但去掉外层 40px 高度限制,避免行高被撑爆。 */
|
||||
.mx-cell-input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
background: var(--mx-s3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--mx-r-xs);
|
||||
color: var(--mx-text);
|
||||
font-size: 13px;
|
||||
font-family: var(--mx-font);
|
||||
transition:
|
||||
border-color var(--mx-d1) var(--mx-ease),
|
||||
box-shadow var(--mx-d1) var(--mx-ease);
|
||||
}
|
||||
.mx-cell-input:focus {
|
||||
border-color: var(--mx-primary);
|
||||
box-shadow: 0 0 0 3px var(--mx-primary-glow);
|
||||
outline: none;
|
||||
}
|
||||
.mx-cell-input.is-invalid {
|
||||
border-color: var(--mx-error);
|
||||
}
|
||||
.mx-cell-input.is-dirty {
|
||||
border-color: var(--mx-amber);
|
||||
}
|
||||
.mx-cell-input.mono {
|
||||
font-family: var(--mx-mono);
|
||||
}
|
||||
|
||||
/* 紧凑表格里的单元格不再响应 hover 左推,否则录入时会跳。 */
|
||||
.mx-table-entry tbody tr:hover td {
|
||||
padding-left: 16px;
|
||||
}
|
||||
.mx-table-entry td {
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mx-table-entry tbody tr.is-dirty {
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
}
|
||||
.mx-table-entry tbody tr.is-focus {
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
|
||||
/* 只读访客态:禁用写操作控件。 */
|
||||
.is-readonly .mx-btn-primary,
|
||||
.is-readonly .mx-btn-danger {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
+138
-144
@@ -1,69 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { mix, parseHexColor, readableForeground, toCss } from './color'
|
||||
import type { DisplayPageKey } from './types'
|
||||
import { useScoreboard } from './useScoreboard'
|
||||
import { useStageScale } from './useStageScale'
|
||||
import DetailOverlay from './components/DetailOverlay.vue'
|
||||
import TickerBar from './components/TickerBar.vue'
|
||||
import TopBar from './components/TopBar.vue'
|
||||
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
|
||||
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
|
||||
import EventResultsPage from './components/pages/EventResultsPage.vue'
|
||||
import StandingsPage from './components/pages/StandingsPage.vue'
|
||||
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
|
||||
|
||||
// 占位数据:M1/M3 接入 /api 后替换为实时数据
|
||||
const teams = ref([
|
||||
{ rank: 1, name: '第一分公司', points: 186 },
|
||||
{ rank: 2, name: '机关本部', points: 172 },
|
||||
{ rank: 3, name: '第二分公司', points: 158 },
|
||||
{ rank: 4, name: '第三分公司', points: 141 },
|
||||
{ rank: 5, name: '第四分公司', points: 127 },
|
||||
{ rank: 6, name: '第五分公司', points: 103 },
|
||||
{ rank: 7, name: '第六分公司', points: 95 },
|
||||
])
|
||||
|
||||
const scale = ref(1)
|
||||
const stage = ref<HTMLElement | null>(null)
|
||||
|
||||
function fit() {
|
||||
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
|
||||
scale.value = s
|
||||
if (stage.value) {
|
||||
stage.value.style.left = `${(window.innerWidth - 1920 * s) / 2}px`
|
||||
stage.value.style.top = `${(window.innerHeight - 1080 * s) / 2}px`
|
||||
}
|
||||
const PAGES: Record<DisplayPageKey, Component> = {
|
||||
standings: StandingsPage,
|
||||
'team-medals': TeamMedalsPage,
|
||||
'athlete-standings': AthleteStandingsPage,
|
||||
'event-results': EventResultsPage,
|
||||
announcement: AnnouncementPage,
|
||||
}
|
||||
|
||||
const { bootstrap, dispose, page, settings } = useScoreboard()
|
||||
const { scale, offsetX, offsetY } = useStageScale()
|
||||
|
||||
const currentPage = computed<Component>(() => PAGES[page.value])
|
||||
|
||||
const stageStyle = computed(() => ({
|
||||
transform: `scale(${scale.value})`,
|
||||
left: `${offsetX.value}px`,
|
||||
top: `${offsetY.value}px`,
|
||||
}))
|
||||
|
||||
const background = computed(() => {
|
||||
const value = settings.value?.display.background?.trim()
|
||||
return value !== undefined && value !== '' ? value : null
|
||||
})
|
||||
|
||||
/** 主色由配置驱动,亮/暗变体与前景色在这里一次性算好挂成 CSS 变量。 */
|
||||
const themeVars = computed<Record<string, string>>(() => {
|
||||
const vars: Record<string, string> = {}
|
||||
const primary = parseHexColor(settings.value?.display.primaryColor ?? null)
|
||||
if (primary === null) return vars
|
||||
|
||||
const light = mix(primary, { r: 255, g: 255, b: 255 }, 0.45)
|
||||
const dark = mix(primary, { r: 0, g: 0, b: 0 }, 0.45)
|
||||
vars['--primary'] = toCss(primary)
|
||||
vars['--primary-light'] = toCss(light)
|
||||
vars['--primary-dark'] = toCss(dark)
|
||||
vars['--primary-fg'] = readableForeground(primary)
|
||||
vars['--primary-text'] = toCss(light)
|
||||
vars['--primary-line'] = `rgba(${primary.r}, ${primary.g}, ${primary.b}, 0.38)`
|
||||
vars['--primary-flash'] = `rgba(${primary.r}, ${primary.g}, ${primary.b}, 0.14)`
|
||||
return vars
|
||||
})
|
||||
|
||||
// 无人值守兜底:每 24h 刷新一次,清掉长跑累积的内存与渲染状态(06 第 8 节)。
|
||||
const RELOAD_MS = 24 * 60 * 60 * 1000
|
||||
let reloadTimer = 0
|
||||
|
||||
onMounted(() => {
|
||||
fit()
|
||||
window.addEventListener('resize', fit)
|
||||
void bootstrap()
|
||||
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(reloadTimer)
|
||||
dispose()
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('resize', fit))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="stage" class="stage" :style="{ transform: `scale(${scale})` }">
|
||||
<header class="head">
|
||||
<div class="logo">会标</div>
|
||||
<h1>赛事积分榜</h1>
|
||||
<div class="spacer" />
|
||||
</header>
|
||||
<div class="screen">
|
||||
<div class="stage" :style="[stageStyle, themeVars]">
|
||||
<img v-if="background" class="bg" :src="background" alt="" />
|
||||
<div class="bg-fallback" />
|
||||
|
||||
<section class="board">
|
||||
<h2>总积分榜</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rk">名次</th>
|
||||
<th>单位</th>
|
||||
<th class="pt">总分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in teams" :key="t.name">
|
||||
<td class="rk">{{ t.rank }}</td>
|
||||
<td class="nm">{{ t.name }}</td>
|
||||
<td class="pt">{{ t.points }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div class="content">
|
||||
<TopBar />
|
||||
<main class="main">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="currentPage" :key="page" />
|
||||
</Transition>
|
||||
</main>
|
||||
<TickerBar />
|
||||
</div>
|
||||
|
||||
<footer class="ticker">底栏信息条(轮播占位)</footer>
|
||||
<DetailOverlay />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: none;
|
||||
}
|
||||
.stage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -72,109 +108,67 @@ onUnmounted(() => window.removeEventListener('resize', fit))
|
||||
height: 1080px;
|
||||
transform-origin: top left;
|
||||
padding: 40px 48px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
|
||||
--primary: #4fd8ff;
|
||||
--primary-light: #8fe6ff;
|
||||
--primary-dark: #1b7fa8;
|
||||
--primary-fg: #04121c;
|
||||
--primary-text: #8fe6ff;
|
||||
--primary-line: rgba(79, 216, 255, 0.38);
|
||||
--primary-flash: rgba(79, 216, 255, 0.14);
|
||||
}
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.bg-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 18% 0%, rgba(79, 216, 255, 0.16), transparent 55%),
|
||||
radial-gradient(ellipse at 88% 100%, rgba(255, 215, 94, 0.1), transparent 50%),
|
||||
linear-gradient(160deg, #0b1e3a 0%, #06152b 100%);
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(79, 216, 255, 0.12), transparent 55%),
|
||||
radial-gradient(ellipse at 85% 100%, rgba(255, 215, 94, 0.08), transparent 50%), var(--bg);
|
||||
cursor: none;
|
||||
gap: 20px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
.logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--gold);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
h1 {
|
||||
font-size: 64px;
|
||||
letter-spacing: 8px;
|
||||
background: linear-gradient(180deg, #fff 30%, var(--gold) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.board {
|
||||
.main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 28px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
h2 {
|
||||
font-size: 34px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--cyan);
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid rgba(79, 216, 255, 0.35);
|
||||
margin-bottom: 8px;
|
||||
.main > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
font-size: 30px;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
th {
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
letter-spacing: 2px;
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-24px);
|
||||
}
|
||||
td.rk {
|
||||
width: 140px;
|
||||
font-weight: 800;
|
||||
font-size: 34px;
|
||||
}
|
||||
tbody tr:nth-child(1) td.rk {
|
||||
color: var(--gold);
|
||||
}
|
||||
tbody tr:nth-child(2) td.rk {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
tbody tr:nth-child(3) td.rk {
|
||||
color: #e0995e;
|
||||
}
|
||||
td.nm {
|
||||
text-align: left;
|
||||
padding-left: 32px;
|
||||
}
|
||||
td.pt {
|
||||
width: 220px;
|
||||
color: var(--gold);
|
||||
font-weight: 800;
|
||||
font-size: 36px;
|
||||
}
|
||||
.ticker {
|
||||
height: 88px;
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--panel-border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 26px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 2px;
|
||||
|
||||
:global(body) {
|
||||
cursor: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 主色派生(06 第 2 节):亮色变体做标题渐变、暗色变体做背景光晕,
|
||||
* 前景色按对比度自动选黑/白,保证主色换成任何颜色都仍然可读。
|
||||
*/
|
||||
|
||||
export interface Rgb {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
}
|
||||
|
||||
const HEX_SHORT = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i
|
||||
const HEX_LONG = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i
|
||||
|
||||
export function parseHexColor(value: string | null | undefined): Rgb | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const text = value.trim()
|
||||
const short = HEX_SHORT.exec(text)
|
||||
if (short !== null) {
|
||||
return {
|
||||
r: Number.parseInt(`${short[1]}${short[1]}`, 16),
|
||||
g: Number.parseInt(`${short[2]}${short[2]}`, 16),
|
||||
b: Number.parseInt(`${short[3]}${short[3]}`, 16),
|
||||
}
|
||||
}
|
||||
const long = HEX_LONG.exec(text)
|
||||
if (long !== null) {
|
||||
return {
|
||||
r: Number.parseInt(long[1] ?? '0', 16),
|
||||
g: Number.parseInt(long[2] ?? '0', 16),
|
||||
b: Number.parseInt(long[3] ?? '0', 16),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function mix(a: Rgb, b: Rgb, ratio: number): Rgb {
|
||||
const t = Math.min(1, Math.max(0, ratio))
|
||||
return {
|
||||
r: Math.round(a.r + (b.r - a.r) * t),
|
||||
g: Math.round(a.g + (b.g - a.g) * t),
|
||||
b: Math.round(a.b + (b.b - a.b) * t),
|
||||
}
|
||||
}
|
||||
|
||||
export function toCss(color: Rgb): string {
|
||||
return `rgb(${color.r} ${color.g} ${color.b})`
|
||||
}
|
||||
|
||||
function channelLuminance(channel: number): number {
|
||||
const c = channel / 255
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
export function relativeLuminance(color: Rgb): number {
|
||||
return (
|
||||
0.2126 * channelLuminance(color.r) +
|
||||
0.7152 * channelLuminance(color.g) +
|
||||
0.0722 * channelLuminance(color.b)
|
||||
)
|
||||
}
|
||||
|
||||
function contrastRatio(a: Rgb, b: Rgb): number {
|
||||
const la = relativeLuminance(a)
|
||||
const lb = relativeLuminance(b)
|
||||
const lighter = Math.max(la, lb)
|
||||
const darker = Math.min(la, lb)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
const WHITE: Rgb = { r: 255, g: 255, b: 255 }
|
||||
const INK: Rgb = { r: 11, g: 18, b: 32 }
|
||||
|
||||
/** 主色上放文字:对比度更高的那个(目标 ≥ 4.5:1)。 */
|
||||
export function readableForeground(color: Rgb): string {
|
||||
return contrastRatio(color, WHITE) >= contrastRatio(color, INK) ? '#ffffff' : '#0b1220'
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { formatPoints } from '@/shared'
|
||||
import { prefersReducedMotion } from '../useReducedMotion'
|
||||
|
||||
const props = withDefaults(defineProps<{ value: number; duration?: number }>(), { duration: 900 })
|
||||
|
||||
const reduced = prefersReducedMotion()
|
||||
const shown = ref(props.value)
|
||||
let frame = 0
|
||||
|
||||
// 整数分数滚动时不要出现小数抖动;权重分(非整数)才保留两位。
|
||||
const text = computed(() =>
|
||||
formatPoints(Number.isInteger(props.value) ? Math.round(shown.value) : shown.value),
|
||||
)
|
||||
|
||||
function stop(): void {
|
||||
if (frame !== 0) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
}
|
||||
|
||||
function run(target: number): void {
|
||||
stop()
|
||||
const from = shown.value
|
||||
if (reduced.value || props.duration <= 0 || from === target) {
|
||||
shown.value = target
|
||||
return
|
||||
}
|
||||
|
||||
const started = performance.now()
|
||||
const step = (now: number): void => {
|
||||
const progress = Math.min(1, (now - started) / props.duration)
|
||||
const eased = 1 - (1 - progress) ** 3
|
||||
shown.value = from + (target - from) * eased
|
||||
if (progress < 1) {
|
||||
frame = requestAnimationFrame(step)
|
||||
} else {
|
||||
shown.value = target
|
||||
frame = 0
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
watch(() => props.value, run)
|
||||
onUnmounted(stop)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="animated-number">{{ text }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import AnimatedNumber from './AnimatedNumber.vue'
|
||||
|
||||
const { detail } = useScoreboard()
|
||||
|
||||
const remaining = ref(0)
|
||||
let timer = 0
|
||||
|
||||
// 倒计时按 openedAt 计算而不是每 1s 减一:页面被系统休眠后回来仍显示真实剩余时间。
|
||||
watch(
|
||||
detail,
|
||||
(state) => {
|
||||
window.clearInterval(timer)
|
||||
timer = 0
|
||||
if (state === null) {
|
||||
remaining.value = 0
|
||||
return
|
||||
}
|
||||
const tick = (): void => {
|
||||
const elapsed = (Date.now() - state.openedAt) / 1000
|
||||
remaining.value = Math.max(0, Math.ceil(state.autoCloseSeconds - elapsed))
|
||||
}
|
||||
tick()
|
||||
timer = window.setInterval(tick, 1000)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => window.clearInterval(timer))
|
||||
|
||||
const items = computed(() => detail.value?.detail?.items ?? [])
|
||||
const totalPoints = computed(() => detail.value?.detail?.totalPoints ?? 0)
|
||||
const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="overlay">
|
||||
<div v-if="detail" class="overlay">
|
||||
<div class="scrim" />
|
||||
<section class="panel">
|
||||
<header class="head">
|
||||
<div>
|
||||
<div class="kicker">积分明细</div>
|
||||
<h2>{{ detail.title }}</h2>
|
||||
</div>
|
||||
<div class="total">
|
||||
<AnimatedNumber :value="totalPoints" />
|
||||
<span class="unit">分</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="detail.loading" class="empty">正在获取明细…</div>
|
||||
<div v-else-if="items.length === 0" class="empty">暂无明细记录</div>
|
||||
<div v-else class="table">
|
||||
<div class="row head-row">
|
||||
<span class="col-name">项目 / 组别</span>
|
||||
<span class="col-rank">名次</span>
|
||||
<span class="col-result">成绩</span>
|
||||
<span class="col-points">积分</span>
|
||||
</div>
|
||||
<div v-for="item in items" :key="item.ledgerId" class="row">
|
||||
<span class="col-name">
|
||||
{{ item.eventName ?? '人工调整' }}
|
||||
<em v-if="item.categoryName">{{ item.categoryName }}</em>
|
||||
</span>
|
||||
<span class="col-rank">{{ item.rank ?? '—' }}</span>
|
||||
<span class="col-result">{{ item.resultText ?? '—' }}</span>
|
||||
<span class="col-points">{{ item.points }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<span v-if="autoClose">将在 {{ remaining }}s 后自动关闭</span>
|
||||
<span v-else>等待管理端关闭</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, rgba(4, 12, 26, 0.25) 0%, rgba(4, 12, 26, 0.85) 46%);
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
padding: 40px 44px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(9, 24, 46, 0.98), rgba(7, 18, 36, 0.98));
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
.kicker {
|
||||
font-size: 20px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--primary-text);
|
||||
}
|
||||
h2 {
|
||||
margin-top: 8px;
|
||||
font-size: 46px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
.total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 60px;
|
||||
font-weight: 800;
|
||||
color: var(--gold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.total .unit {
|
||||
font-size: 26px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 110px 160px 130px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 8px;
|
||||
font-size: 27px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.head-row {
|
||||
font-size: 20px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.col-rank,
|
||||
.col-result,
|
||||
.col-points {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.col-points {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
}
|
||||
.col-name em {
|
||||
margin-left: 10px;
|
||||
font-style: normal;
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 30px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.foot {
|
||||
flex: none;
|
||||
padding-top: 16px;
|
||||
text-align: right;
|
||||
font-size: 22px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.overlay-enter-active,
|
||||
.overlay-leave-active {
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
.overlay-enter-from,
|
||||
.overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
|
||||
const { booted, status, source, version } = useScoreboard()
|
||||
|
||||
const offline = computed(() => booted.value && status.value !== 'online')
|
||||
const mock = computed(() => source.value === 'mock')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="badges">
|
||||
<span v-if="mock" class="badge mock">示例数据</span>
|
||||
<span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span>
|
||||
<span v-if="version !== null" class="badge version">v{{ version }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badges {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.badge {
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 1px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.offline {
|
||||
background: rgba(255, 96, 96, 0.16);
|
||||
border-color: rgba(255, 96, 96, 0.55);
|
||||
color: #ffb4b4;
|
||||
}
|
||||
.mock {
|
||||
background: rgba(255, 215, 94, 0.16);
|
||||
border-color: rgba(255, 215, 94, 0.5);
|
||||
color: var(--gold);
|
||||
}
|
||||
.version {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; hint?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<h2>{{ title }}</h2>
|
||||
<span v-if="hint" class="hint">{{ hint }}</span>
|
||||
</header>
|
||||
<div class="page-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 18px;
|
||||
padding: 18px 28px 22px;
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
h2 {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
color: var(--primary-text);
|
||||
}
|
||||
.hint {
|
||||
font-size: 20px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
|
||||
type TickerKind = 'medals' | 'results' | 'announcement'
|
||||
|
||||
interface TickerItem {
|
||||
key: TickerKind
|
||||
label: string
|
||||
}
|
||||
|
||||
const { standings, latestResults, announcement, detail, settings } = useScoreboard()
|
||||
|
||||
const topMedals = computed(() =>
|
||||
[...standings.value]
|
||||
.sort((a, b) => b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze)
|
||||
.slice(0, 3),
|
||||
)
|
||||
|
||||
const latestGroup = computed(() => latestResults.value[0] ?? null)
|
||||
|
||||
const items = computed<TickerItem[]>(() => {
|
||||
const result: TickerItem[] = []
|
||||
if (topMedals.value.length > 0) result.push({ key: 'medals', label: '奖牌榜 Top3' })
|
||||
if (latestGroup.value !== null) result.push({ key: 'results', label: '最新完成项目' })
|
||||
// 公告是固定槽位:没有公告时显示占位,避免底栏只剩两屏来回跳。
|
||||
result.push({ key: 'announcement', label: '公告' })
|
||||
return result
|
||||
})
|
||||
|
||||
const index = ref(0)
|
||||
const current = computed<TickerItem | null>(() => items.value[index.value] ?? items.value[0] ?? null)
|
||||
const paused = computed(() => detail.value !== null)
|
||||
const rotateMs = computed(() => Math.max(2, settings.value?.display.rotateSeconds ?? 8) * 1000)
|
||||
|
||||
let timer = 0
|
||||
|
||||
function restart(): void {
|
||||
window.clearInterval(timer)
|
||||
timer = 0
|
||||
if (paused.value) return
|
||||
timer = window.setInterval(() => {
|
||||
index.value = (index.value + 1) % Math.max(1, items.value.length)
|
||||
}, rotateMs.value)
|
||||
}
|
||||
|
||||
watch([rotateMs, paused], restart, { immediate: true })
|
||||
watch(
|
||||
() => items.value.length,
|
||||
(total) => {
|
||||
if (index.value >= total) index.value = 0
|
||||
},
|
||||
)
|
||||
onUnmounted(() => window.clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="ticker">
|
||||
<div v-if="current" class="label">{{ current.label }}</div>
|
||||
<div class="viewport">
|
||||
<div v-if="current" :key="current.key" class="item">
|
||||
<template v-if="current.key === 'medals'">
|
||||
<div v-for="(row, i) in topMedals" :key="row.teamId" class="chip">
|
||||
<span class="chip-rank">{{ i + 1 }}</span>
|
||||
<span class="chip-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
<span class="chip-stat gold">金 {{ row.gold }}</span>
|
||||
<span class="chip-stat silver">银 {{ row.silver }}</span>
|
||||
<span class="chip-stat bronze">铜 {{ row.bronze }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="current.key === 'results' && latestGroup">
|
||||
<div class="event-name">
|
||||
{{ latestGroup.eventName }}
|
||||
<span class="event-cat">{{ latestGroup.categoryName }}</span>
|
||||
</div>
|
||||
<div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip">
|
||||
<span class="chip-rank">{{ row.rank ?? '—' }}</span>
|
||||
<span class="chip-name">{{ row.athleteName ?? row.teamName }}</span>
|
||||
<span class="chip-stat">{{ row.resultText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="announce">{{ announcement?.text ?? '暂无公告' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ticker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
height: 112px;
|
||||
flex: none;
|
||||
padding: 0 28px;
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--panel-border);
|
||||
}
|
||||
.label {
|
||||
flex: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 22px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--primary-fg);
|
||||
background: var(--primary);
|
||||
}
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.item {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 34px;
|
||||
white-space: nowrap;
|
||||
/* 只做入场淡入:切换时旧内容立即移除,不会因为离场过渡卡住而出现空档。 */
|
||||
animation: ticker-in 0.4s ease-out both;
|
||||
}
|
||||
@keyframes ticker-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(36px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 26px;
|
||||
}
|
||||
.chip-rank {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-dim);
|
||||
font-size: 22px;
|
||||
}
|
||||
.chip-name {
|
||||
color: var(--text);
|
||||
}
|
||||
.chip-stat {
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gold {
|
||||
color: var(--gold);
|
||||
}
|
||||
.silver {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.bronze {
|
||||
color: #e0995e;
|
||||
}
|
||||
.event-name {
|
||||
font-size: 28px;
|
||||
color: var(--primary-light);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.event-cat {
|
||||
margin-left: 12px;
|
||||
font-size: 22px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.announce {
|
||||
font-size: 28px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { formatClock } from '@/shared'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import OfflineBadge from './OfflineBadge.vue'
|
||||
|
||||
const { settings, options } = useScoreboard()
|
||||
|
||||
const now = ref(new Date().toISOString())
|
||||
const timer = window.setInterval(() => {
|
||||
now.value = new Date().toISOString()
|
||||
}, 15_000)
|
||||
onUnmounted(() => window.clearInterval(timer))
|
||||
|
||||
const display = computed(() => settings.value?.display ?? null)
|
||||
const title = computed(() => display.value?.title.trim() || '赛事标题待配置')
|
||||
const subtitle = computed(() => display.value?.subtitle?.trim() ?? '')
|
||||
const host = computed(() => settings.value?.event.host?.trim() || '主办方待配置')
|
||||
const logo = computed(() => display.value?.logo?.trim() || '')
|
||||
const clock = computed(() => formatClock(now.value))
|
||||
const identity = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (options.value.instance !== null) parts.push(`实例 ${options.value.instance}`)
|
||||
if (options.value.screen !== null) parts.push(`屏 ${options.value.screen}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top">
|
||||
<div class="logo">
|
||||
<img v-if="logo" :src="logo" alt="" />
|
||||
<span v-else>会标</span>
|
||||
</div>
|
||||
|
||||
<div class="titles">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="clock">{{ clock }}</div>
|
||||
<div class="host">{{ host }}</div>
|
||||
<div v-if="identity" class="identity">{{ identity }}</div>
|
||||
<OfflineBadge />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
min-height: 132px;
|
||||
flex: none;
|
||||
}
|
||||
.logo {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--primary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
}
|
||||
.logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.titles {
|
||||
min-width: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 62px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 8px;
|
||||
white-space: nowrap;
|
||||
background: linear-gradient(180deg, #ffffff 25%, var(--primary-light) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
letter-spacing: 6px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.right {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
flex: none;
|
||||
}
|
||||
.clock {
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.host {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.identity {
|
||||
margin-top: 4px;
|
||||
font-size: 18px;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(157, 180, 212, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
|
||||
const { announcement } = useScoreboard()
|
||||
|
||||
const text = computed(() => announcement.value?.text ?? '暂无公告')
|
||||
// 重复三遍 + padding-left 100%,滚动到末尾时下一条刚好接上,不出现空白。
|
||||
const marquee = computed(() => `${text.value} · ${text.value} · ${text.value}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="公告">
|
||||
<div class="body">
|
||||
<p class="text">{{ text }}</p>
|
||||
</div>
|
||||
<div class="marquee">
|
||||
<div class="marquee-inner">{{ marquee }}</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 40px;
|
||||
}
|
||||
.text {
|
||||
font-size: 56px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 6px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
.marquee {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 16px 0 4px;
|
||||
border-top: 2px solid var(--primary-line);
|
||||
}
|
||||
.marquee-inner {
|
||||
display: inline-block;
|
||||
padding-left: 100%;
|
||||
font-size: 28px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
animation: marquee 26s linear infinite;
|
||||
}
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { fitRowFont } from '../../fitFont'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import AnimatedNumber from '../AnimatedNumber.vue'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
|
||||
const { athletes, settings } = useScoreboard()
|
||||
|
||||
const rows = computed(() => athletes.value)
|
||||
const showMedals = computed(() => settings.value?.standings.showIndividualBoard ?? true)
|
||||
|
||||
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="个人积分奖牌榜">
|
||||
<div class="board" :class="{ 'no-medals': !showMedals }" :style="{ '--row-font': rowFont }">
|
||||
<div class="row head">
|
||||
<span>名次</span>
|
||||
<span class="name">姓名</span>
|
||||
<span class="team">单位</span>
|
||||
<span class="points">总分</span>
|
||||
<template v-if="showMedals">
|
||||
<span class="medal gold">金</span>
|
||||
<span class="medal silver">银</span>
|
||||
<span class="medal bronze">铜</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-for="row in rows" :key="row.athleteId" class="row">
|
||||
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
||||
<span class="name">{{ row.athleteName }}</span>
|
||||
<span class="team">{{ row.shortName ?? row.teamName }}</span>
|
||||
<span class="points"><AnimatedNumber :value="row.totalPoints" /></span>
|
||||
<template v-if="showMedals">
|
||||
<span class="medal gold">{{ row.gold }}</span>
|
||||
<span class="medal silver">{{ row.silver }}</span>
|
||||
<span class="medal bronze">{{ row.bronze }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="rows.length === 0" class="empty">暂无个人积分数据</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr 1fr 200px 110px 110px 110px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 18px;
|
||||
font-size: var(--row-font, 42px);
|
||||
line-height: 1.1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.no-medals .row {
|
||||
grid-template-columns: 130px 1fr 1fr 220px;
|
||||
}
|
||||
.head {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
.rank,
|
||||
.medal,
|
||||
.points {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rank {
|
||||
font-weight: 800;
|
||||
}
|
||||
.top-1 {
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-2 {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.top-3 {
|
||||
color: #e0995e;
|
||||
}
|
||||
.name,
|
||||
.team {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82em;
|
||||
}
|
||||
.points {
|
||||
text-align: right;
|
||||
font-weight: 800;
|
||||
color: var(--gold);
|
||||
}
|
||||
.medal {
|
||||
text-align: center;
|
||||
}
|
||||
.gold {
|
||||
color: var(--gold);
|
||||
}
|
||||
.silver {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.bronze {
|
||||
color: #e0995e;
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatResultText } from '@/shared'
|
||||
import type { EventResultRow, ResultStatus } from '@/shared'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
|
||||
const { latestResults, options } = useScoreboard()
|
||||
|
||||
// 数据口径:state='published' 且 status='finished',按发布时间倒序取前 N 个(06 第 5 节)。
|
||||
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
|
||||
|
||||
const STATUS_LABELS: Record<ResultStatus, string> = {
|
||||
ok: '',
|
||||
dq: 'DQ',
|
||||
dns: 'DNS',
|
||||
dnf: 'DNF',
|
||||
withdrawn: '退赛',
|
||||
}
|
||||
|
||||
function displayResult(row: EventResultRow): string {
|
||||
if (row.status !== 'ok') return STATUS_LABELS[row.status]
|
||||
if (row.resultValue === null) return row.resultText
|
||||
return formatResultText(row.resultValue, row.unit, row.unit === 's' ? 'time' : 'none')
|
||||
}
|
||||
|
||||
function displayName(row: EventResultRow): string {
|
||||
if (row.athleteName !== null && row.athleteName !== '') return row.athleteName
|
||||
if (row.memberNames !== null) return row.memberNames.join(' / ')
|
||||
return row.shortName ?? row.teamName
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="分项成绩" :hint="`最新完成 ${groups.length} 项`">
|
||||
<div v-if="groups.length > 0" class="groups">
|
||||
<article v-for="group in groups" :key="`${group.eventId}-${group.categoryId}`" class="group">
|
||||
<header class="group-head">
|
||||
<h3>{{ group.eventName }}</h3>
|
||||
<span class="cat">{{ group.categoryName }}</span>
|
||||
</header>
|
||||
<div class="lines">
|
||||
<div
|
||||
v-for="row in group.rows.slice(0, 6)"
|
||||
:key="row.entryId"
|
||||
class="line"
|
||||
:class="`top-${Math.min(row.rank ?? 99, 4)}`"
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<span class="team">{{ row.shortName ?? row.teamName }}</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty">暂无已发布项目</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.groups {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 26px;
|
||||
}
|
||||
.group {
|
||||
flex: 1 1 0;
|
||||
max-width: 620px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
border: 1px solid var(--primary-line);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
h3 {
|
||||
font-size: 34px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--primary-light);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cat {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.lines {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 150px 170px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 6px;
|
||||
font-size: 28px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
animation: row-in 0.45s ease-out both;
|
||||
}
|
||||
.line:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rk {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.nm {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.team {
|
||||
font-size: 22px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rs {
|
||||
text-align: right;
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.top-1 .rk {
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-2 .rk {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.top-3 .rk {
|
||||
color: #e0995e;
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatRankChange } from '@/shared'
|
||||
import { fitRowFont } from '../../fitFont'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import AnimatedNumber from '../AnimatedNumber.vue'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
|
||||
const { standings, rankDeltaFor } = useScoreboard()
|
||||
|
||||
const rows = computed(() => standings.value)
|
||||
|
||||
// 行高由 flex 均分,字号按行数算,单位再多也不会顶到轮播条下面。
|
||||
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="总积分榜">
|
||||
<div class="board" :style="{ '--row-font': rowFont }">
|
||||
<div class="row head">
|
||||
<span class="rank">名次</span>
|
||||
<span class="name">单位</span>
|
||||
<span class="delta" />
|
||||
<span class="points">总分</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.teamId"
|
||||
class="row"
|
||||
:class="{ flash: rankDeltaFor(row.teamId) !== null }"
|
||||
>
|
||||
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
||||
<span class="name">{{ row.teamName }}</span>
|
||||
<span class="delta">
|
||||
<span v-if="rankDeltaFor(row.teamId) !== null" :class="rankDeltaFor(row.teamId)! > 0 ? 'up' : 'down'">
|
||||
{{ formatRankChange(rankDeltaFor(row.teamId)) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="points"><AnimatedNumber :value="row.totalPoints" /></span>
|
||||
</div>
|
||||
<div v-if="rows.length === 0" class="empty">暂无积分数据</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr 110px 260px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 18px;
|
||||
font-size: var(--row-font, 48px);
|
||||
line-height: 1.1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
transition:
|
||||
background-color 0.4s ease,
|
||||
box-shadow 0.4s ease;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.head {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
.rank {
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.top-1 {
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-2 {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.top-3 {
|
||||
color: #e0995e;
|
||||
}
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.points {
|
||||
text-align: right;
|
||||
font-weight: 800;
|
||||
color: var(--gold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.delta {
|
||||
text-align: right;
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
}
|
||||
.up {
|
||||
color: #5cf08a;
|
||||
}
|
||||
.down {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
.flash {
|
||||
background: var(--primary-flash);
|
||||
box-shadow: inset 4px 0 0 var(--primary);
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { fitRowFont } from '../../fitFont'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
|
||||
const { standings } = useScoreboard()
|
||||
|
||||
const rows = computed(() =>
|
||||
[...standings.value].sort(
|
||||
(a, b) =>
|
||||
b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze || b.totalPoints - a.totalPoints,
|
||||
),
|
||||
)
|
||||
|
||||
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFrame title="团体奖牌榜">
|
||||
<div class="board" :style="{ '--row-font': rowFont }">
|
||||
<div class="row head">
|
||||
<span>名次</span>
|
||||
<span class="name">单位</span>
|
||||
<span class="medal gold">金</span>
|
||||
<span class="medal silver">银</span>
|
||||
<span class="medal bronze">铜</span>
|
||||
<span class="total">合计</span>
|
||||
</div>
|
||||
<div v-for="(row, index) in rows" :key="row.teamId" class="row">
|
||||
<span class="rank" :class="`top-${Math.min(index + 1, 4)}`">{{ index + 1 }}</span>
|
||||
<span class="name">{{ row.teamName }}</span>
|
||||
<span class="medal gold">{{ row.gold }}</span>
|
||||
<span class="medal silver">{{ row.silver }}</span>
|
||||
<span class="medal bronze">{{ row.bronze }}</span>
|
||||
<span class="total">{{ row.medalTotal }}</span>
|
||||
</div>
|
||||
<div v-if="rows.length === 0" class="empty">暂无奖牌数据</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr 140px 140px 140px 180px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
padding: 0 18px;
|
||||
font-size: var(--row-font, 44px);
|
||||
line-height: 1.1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.head {
|
||||
flex: none;
|
||||
font-size: 22px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 2px solid var(--primary-line);
|
||||
}
|
||||
.rank,
|
||||
.medal,
|
||||
.total {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rank {
|
||||
font-weight: 800;
|
||||
}
|
||||
.top-1 {
|
||||
color: var(--gold);
|
||||
}
|
||||
.top-2 {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.top-3 {
|
||||
color: #e0995e;
|
||||
}
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gold {
|
||||
color: var(--gold);
|
||||
}
|
||||
.silver {
|
||||
color: #d8e2ee;
|
||||
}
|
||||
.bronze {
|
||||
color: #e0995e;
|
||||
}
|
||||
.total {
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。
|
||||
*
|
||||
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
|
||||
* 比例留出 padding 与行距,保证文字始终装得下。
|
||||
*/
|
||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||
if (count <= 0) return '48px'
|
||||
const perRow = availablePx / count
|
||||
const size = Math.min(56, Math.max(16, perRow * ratio))
|
||||
return `${Math.floor(size)}px`
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type {
|
||||
AthleteStanding,
|
||||
BreakdownItem,
|
||||
CompareMode,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
EventResultRow,
|
||||
EventSettings,
|
||||
EventStatus,
|
||||
EventType,
|
||||
Gender,
|
||||
LedgerSourceType,
|
||||
RecordEntry,
|
||||
RecordLevel,
|
||||
RecordSource,
|
||||
ResultMode,
|
||||
ResultStatus,
|
||||
ResultType,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
} from '@/shared'
|
||||
|
||||
/**
|
||||
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
|
||||
*
|
||||
* 大屏是无人值守的:一条脏数据不能把整页渲染炸掉,所以这里逐字段兜底,
|
||||
* 而不是直接断言成后端类型。
|
||||
*/
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function list(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
export function str(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
export function strOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
export function num(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
export function numOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function bool(value: unknown, fallback = false): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback
|
||||
}
|
||||
|
||||
export function strings(value: unknown): string[] | null {
|
||||
const items = list(value).filter((item): item is string => typeof item === 'string')
|
||||
return items.length === 0 ? null : items
|
||||
}
|
||||
|
||||
export function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback
|
||||
}
|
||||
|
||||
const EVENT_TYPES = ['individual', 'team', 'match'] as const satisfies readonly EventType[]
|
||||
const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
|
||||
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
|
||||
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
|
||||
const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[]
|
||||
const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[]
|
||||
const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
|
||||
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
|
||||
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
|
||||
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
|
||||
|
||||
function record(source: unknown): Record<string, unknown> {
|
||||
return isRecord(source) ? source : {}
|
||||
}
|
||||
|
||||
interface TeamRefShape {
|
||||
teamId: number
|
||||
teamCode: string
|
||||
teamName: string
|
||||
shortName: string | null
|
||||
color: string | null
|
||||
logoPath: string | null
|
||||
}
|
||||
|
||||
function parseTeamRef(source: unknown): TeamRefShape {
|
||||
const rec = record(source)
|
||||
return {
|
||||
teamId: num(rec.teamId),
|
||||
teamCode: str(rec.teamCode),
|
||||
teamName: str(rec.teamName),
|
||||
shortName: strOrNull(rec.shortName),
|
||||
color: strOrNull(rec.color),
|
||||
logoPath: strOrNull(rec.logoPath),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTeam(source: unknown): Team {
|
||||
const rec = record(source)
|
||||
return {
|
||||
id: num(rec.id),
|
||||
code: str(rec.code),
|
||||
name: str(rec.name),
|
||||
shortName: strOrNull(rec.shortName),
|
||||
logoPath: strOrNull(rec.logoPath),
|
||||
color: strOrNull(rec.color),
|
||||
sortOrder: num(rec.sortOrder),
|
||||
active: bool(rec.active, true),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEvent(source: unknown): Event {
|
||||
const rec = record(source)
|
||||
return {
|
||||
id: num(rec.id),
|
||||
sportId: num(rec.sportId),
|
||||
code: str(rec.code),
|
||||
name: str(rec.name),
|
||||
eventType: oneOf(rec.eventType, EVENT_TYPES, 'individual'),
|
||||
resultMode: oneOf(rec.resultMode, RESULT_MODES, 'auto'),
|
||||
resultType: oneOf(rec.resultType, RESULT_TYPES, 'none'),
|
||||
compare: oneOf(rec.compare, COMPARE_MODES, 'none'),
|
||||
recordEligible: bool(rec.recordEligible),
|
||||
scored: bool(rec.scored, true),
|
||||
multiplier: num(rec.multiplier, 1),
|
||||
status: oneOf(rec.status, EVENT_STATUSES, 'pending'),
|
||||
scheduledAt: strOrNull(rec.scheduledAt),
|
||||
venue: strOrNull(rec.venue),
|
||||
sortOrder: num(rec.sortOrder),
|
||||
remark: strOrNull(rec.remark),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStandingRow(source: unknown): StandingRow {
|
||||
const rec = record(source)
|
||||
const gold = num(rec.gold)
|
||||
const silver = num(rec.silver)
|
||||
const bronze = num(rec.bronze)
|
||||
return {
|
||||
...parseTeamRef(rec),
|
||||
rank: num(rec.rank),
|
||||
gold,
|
||||
silver,
|
||||
bronze,
|
||||
medalTotal: num(rec.medalTotal, gold + silver + bronze),
|
||||
totalPoints: num(rec.totalPoints),
|
||||
rankChange: numOrNull(rec.rankChange),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStandingRows(value: unknown): StandingRow[] {
|
||||
return list(value).map(parseStandingRow)
|
||||
}
|
||||
|
||||
export function parseAthleteStanding(source: unknown): AthleteStanding {
|
||||
const rec = record(source)
|
||||
return {
|
||||
rank: num(rec.rank),
|
||||
athleteId: num(rec.athleteId),
|
||||
athleteName: str(rec.athleteName),
|
||||
gender: rec.gender === null || rec.gender === undefined ? null : oneOf(rec.gender, GENDERS, '男'),
|
||||
teamId: num(rec.teamId),
|
||||
teamCode: str(rec.teamCode),
|
||||
teamName: str(rec.teamName),
|
||||
shortName: strOrNull(rec.shortName),
|
||||
totalPoints: num(rec.totalPoints),
|
||||
gold: num(rec.gold),
|
||||
silver: num(rec.silver),
|
||||
bronze: num(rec.bronze),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAthleteStandings(value: unknown): AthleteStanding[] {
|
||||
return list(value).map(parseAthleteStanding)
|
||||
}
|
||||
|
||||
export function parseEventResultRow(source: unknown): EventResultRow {
|
||||
const rec = record(source)
|
||||
return {
|
||||
entryId: num(rec.entryId),
|
||||
rank: numOrNull(rec.rank),
|
||||
teamId: num(rec.teamId),
|
||||
teamCode: str(rec.teamCode),
|
||||
teamName: str(rec.teamName),
|
||||
shortName: strOrNull(rec.shortName),
|
||||
athleteId: numOrNull(rec.athleteId),
|
||||
athleteName: strOrNull(rec.athleteName),
|
||||
memberNames: strings(rec.memberNames),
|
||||
resultText: str(rec.resultText, '—'),
|
||||
resultValue: numOrNull(rec.resultValue),
|
||||
unit: str(rec.unit),
|
||||
isTie: bool(rec.isTie),
|
||||
status: oneOf(rec.status, RESULT_STATUSES, 'ok'),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEventResultGroup(source: unknown): EventResultGroup {
|
||||
const rec = record(source)
|
||||
return {
|
||||
eventId: num(rec.eventId),
|
||||
eventCode: str(rec.eventCode),
|
||||
eventName: str(rec.eventName),
|
||||
categoryId: num(rec.categoryId),
|
||||
categoryName: str(rec.categoryName),
|
||||
publishedAt: strOrNull(rec.publishedAt),
|
||||
rows: list(rec.rows).map(parseEventResultRow),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEventResultGroups(value: unknown): EventResultGroup[] {
|
||||
return list(value).map(parseEventResultGroup)
|
||||
}
|
||||
|
||||
export function parseRecordEntry(source: unknown): RecordEntry {
|
||||
const rec = record(source)
|
||||
return {
|
||||
id: num(rec.id),
|
||||
eventId: num(rec.eventId),
|
||||
level: oneOf(rec.level, RECORD_LEVELS, 'meet'),
|
||||
resultValue: num(rec.resultValue),
|
||||
resultText: str(rec.resultText, '—'),
|
||||
holderName: strOrNull(rec.holderName),
|
||||
holderTeamId: numOrNull(rec.holderTeamId),
|
||||
achievedAt: strOrNull(rec.achievedAt),
|
||||
isCurrent: bool(rec.isCurrent),
|
||||
source: oneOf(rec.source, RECORD_SOURCES, 'history'),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSettings(source: unknown): EventSettings {
|
||||
const rec = record(source)
|
||||
const event = record(rec.event)
|
||||
const display = record(rec.display)
|
||||
const standings = record(rec.standings)
|
||||
return {
|
||||
event: {
|
||||
name: str(event.name),
|
||||
subtitle: strOrNull(event.subtitle),
|
||||
host: strOrNull(event.host),
|
||||
logoPath: strOrNull(event.logoPath),
|
||||
primaryColor: strOrNull(event.primaryColor),
|
||||
},
|
||||
display: {
|
||||
background: strOrNull(display.background),
|
||||
logo: strOrNull(display.logo),
|
||||
title: str(display.title),
|
||||
subtitle: strOrNull(display.subtitle),
|
||||
primaryColor: strOrNull(display.primaryColor),
|
||||
rotateSeconds: num(display.rotateSeconds, 8),
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: num(standings.individualToTeamWeight, 1),
|
||||
rankingMode: str(standings.rankingMode, 'points'),
|
||||
showIndividualBoard: bool(standings.showIndividualBoard, true),
|
||||
},
|
||||
defaultProfileId: numOrNull(rec.defaultProfileId),
|
||||
requireApproval: bool(rec.requireApproval),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBreakdownItem(source: unknown): BreakdownItem {
|
||||
const rec = record(source)
|
||||
return {
|
||||
ledgerId: num(rec.ledgerId),
|
||||
sourceType: oneOf(rec.sourceType, LEDGER_SOURCES, 'result'),
|
||||
eventId: numOrNull(rec.eventId),
|
||||
eventCode: strOrNull(rec.eventCode),
|
||||
eventName: strOrNull(rec.eventName),
|
||||
categoryName: strOrNull(rec.categoryName),
|
||||
athleteId: numOrNull(rec.athleteId),
|
||||
athleteName: strOrNull(rec.athleteName),
|
||||
rank: numOrNull(rec.rank),
|
||||
resultText: strOrNull(rec.resultText),
|
||||
points: num(rec.points),
|
||||
createdAt: str(rec.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
|
||||
if (!isRecord(value)) return null
|
||||
return {
|
||||
teamId: num(value.teamId),
|
||||
teamCode: str(value.teamCode),
|
||||
teamName: str(value.teamName),
|
||||
totalPoints: num(value.totalPoints),
|
||||
items: list(value.items).map(parseBreakdownItem),
|
||||
}
|
||||
}
|
||||
|
||||
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
|
||||
export function parseSnapshot(value: unknown): SnapshotMessage | null {
|
||||
if (!isRecord(value)) return null
|
||||
const version = numOrNull(value.version)
|
||||
if (version === null) return null
|
||||
return {
|
||||
version,
|
||||
settings: parseSettings(value.settings),
|
||||
teams: list(value.teams).map(parseTeam),
|
||||
events: list(value.events).map(parseEvent),
|
||||
standings: parseStandingRows(value.standings),
|
||||
records: list(value.records).map(parseRecordEntry),
|
||||
latestResults: parseEventResultGroups(value.latestResults),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type {
|
||||
AthleteStanding,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
EventResultRow,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
|
||||
/**
|
||||
* 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。
|
||||
*
|
||||
* 数值是手写的固定值而不是随机数:现场调试时截图对比不会因为每次刷新而变。
|
||||
*/
|
||||
|
||||
const TEAM_NAMES: readonly string[] = [
|
||||
'第一分公司',
|
||||
'机关本部',
|
||||
'第二分公司',
|
||||
'第三分公司',
|
||||
'第四分公司',
|
||||
'第五分公司',
|
||||
'第六分公司',
|
||||
'技术研究院',
|
||||
]
|
||||
|
||||
function team(index: number, code: string): Team {
|
||||
const name = TEAM_NAMES[index] ?? `单位${index + 1}`
|
||||
return {
|
||||
id: index + 1,
|
||||
code,
|
||||
name,
|
||||
shortName: null,
|
||||
logoPath: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_TEAMS: Team[] = [
|
||||
team(0, 'T01'),
|
||||
team(1, 'T02'),
|
||||
team(2, 'T03'),
|
||||
team(3, 'T04'),
|
||||
team(4, 'T05'),
|
||||
team(5, 'T06'),
|
||||
team(6, 'T07'),
|
||||
team(7, 'T08'),
|
||||
]
|
||||
|
||||
interface StandingSeed {
|
||||
rank: number
|
||||
totalPoints: number
|
||||
gold: number
|
||||
silver: number
|
||||
bronze: number
|
||||
rankChange: number | null
|
||||
}
|
||||
|
||||
const STANDING_SEEDS: readonly StandingSeed[] = [
|
||||
{ rank: 1, totalPoints: 186, gold: 8, silver: 5, bronze: 3, rankChange: 1 },
|
||||
{ rank: 2, totalPoints: 172, gold: 6, silver: 7, bronze: 4, rankChange: -1 },
|
||||
{ rank: 3, totalPoints: 158, gold: 5, silver: 4, bronze: 8, rankChange: 0 },
|
||||
{ rank: 4, totalPoints: 141, gold: 4, silver: 5, bronze: 6, rankChange: 2 },
|
||||
{ rank: 5, totalPoints: 127, gold: 3, silver: 4, bronze: 5, rankChange: null },
|
||||
{ rank: 6, totalPoints: 103, gold: 2, silver: 3, bronze: 4, rankChange: 0 },
|
||||
{ rank: 7, totalPoints: 95, gold: 2, silver: 2, bronze: 2, rankChange: -2 },
|
||||
{ rank: 8, totalPoints: 78, gold: 1, silver: 1, bronze: 3, rankChange: 0 },
|
||||
]
|
||||
|
||||
function buildStandings(): StandingRow[] {
|
||||
return MOCK_TEAMS.map((item, index) => {
|
||||
const seed = STANDING_SEEDS[index] ?? STANDING_SEEDS[STANDING_SEEDS.length - 1]
|
||||
const row = seed ?? { rank: index + 1, totalPoints: 0, gold: 0, silver: 0, bronze: 0, rankChange: null }
|
||||
return {
|
||||
teamId: item.id,
|
||||
teamCode: item.code,
|
||||
teamName: item.name,
|
||||
shortName: item.shortName,
|
||||
color: item.color,
|
||||
logoPath: item.logoPath,
|
||||
rank: row.rank,
|
||||
gold: row.gold,
|
||||
silver: row.silver,
|
||||
bronze: row.bronze,
|
||||
medalTotal: row.gold + row.silver + row.bronze,
|
||||
totalPoints: row.totalPoints,
|
||||
rankChange: row.rankChange,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildEvents(): Event[] {
|
||||
const seeds: readonly [string, string, Event['resultType'], Event['compare']][] = [
|
||||
['E1001', '男子 100 米', 'time', 'asc'],
|
||||
['E1002', '女子 100 米', 'time', 'asc'],
|
||||
['E2001', '男子跳远', 'distance', 'desc'],
|
||||
['E3001', '女子 4×100 米接力', 'time', 'asc'],
|
||||
['E4001', '拔河', 'count', 'desc'],
|
||||
]
|
||||
return seeds.map(([code, name, resultType, compare], index) => ({
|
||||
id: index + 1,
|
||||
sportId: 1,
|
||||
code,
|
||||
name,
|
||||
eventType: index === 3 || index === 4 ? 'team' : 'individual',
|
||||
resultMode: 'auto',
|
||||
resultType,
|
||||
compare,
|
||||
recordEligible: index < 3,
|
||||
scored: true,
|
||||
multiplier: 1,
|
||||
status: index < 3 ? 'published' : 'finished',
|
||||
scheduledAt: null,
|
||||
venue: null,
|
||||
sortOrder: index,
|
||||
remark: null,
|
||||
}))
|
||||
}
|
||||
|
||||
interface ResultSeed {
|
||||
rank: number
|
||||
teamIndex: number
|
||||
athlete: string
|
||||
resultText: string
|
||||
resultValue: number
|
||||
}
|
||||
|
||||
function buildRows(seeds: readonly ResultSeed[], unit: string): EventResultRow[] {
|
||||
return seeds.map((seed, index) => {
|
||||
const team = MOCK_TEAMS[seed.teamIndex]
|
||||
return {
|
||||
entryId: (seed.rank + 1) * 100 + index,
|
||||
rank: seed.rank,
|
||||
teamId: team?.id ?? 0,
|
||||
teamCode: team?.code ?? '',
|
||||
teamName: team?.name ?? '',
|
||||
shortName: team?.shortName ?? null,
|
||||
athleteId: index + 1,
|
||||
athleteName: seed.athlete,
|
||||
memberNames: null,
|
||||
resultText: seed.resultText,
|
||||
resultValue: seed.resultValue,
|
||||
unit,
|
||||
isTie: false,
|
||||
status: 'ok',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildLatestResults(): EventResultGroup[] {
|
||||
return [
|
||||
{
|
||||
eventId: 1,
|
||||
eventCode: 'E1001',
|
||||
eventName: '男子 100 米',
|
||||
categoryId: 1,
|
||||
categoryName: '青年组',
|
||||
publishedAt: '2026-09-09T15:42:00+08:00',
|
||||
rows: buildRows(
|
||||
[
|
||||
{ rank: 1, teamIndex: 0, athlete: '张伟', resultText: '11.58', resultValue: 11.58 },
|
||||
{ rank: 2, teamIndex: 2, athlete: '李强', resultText: '11.72', resultValue: 11.72 },
|
||||
{ rank: 3, teamIndex: 1, athlete: '王磊', resultText: '11.85', resultValue: 11.85 },
|
||||
{ rank: 4, teamIndex: 4, athlete: '刘洋', resultText: '11.94', resultValue: 11.94 },
|
||||
{ rank: 5, teamIndex: 3, athlete: '陈晨', resultText: '12.03', resultValue: 12.03 },
|
||||
{ rank: 6, teamIndex: 5, athlete: '赵鹏', resultText: '12.15', resultValue: 12.15 },
|
||||
],
|
||||
's',
|
||||
),
|
||||
},
|
||||
{
|
||||
eventId: 2,
|
||||
eventCode: 'E1002',
|
||||
eventName: '女子 100 米',
|
||||
categoryId: 2,
|
||||
categoryName: '青年组',
|
||||
publishedAt: '2026-09-09T15:10:00+08:00',
|
||||
rows: buildRows(
|
||||
[
|
||||
{ rank: 1, teamIndex: 1, athlete: '李娜', resultText: '13.04', resultValue: 13.04 },
|
||||
{ rank: 2, teamIndex: 0, athlete: '孙悦', resultText: '13.21', resultValue: 13.21 },
|
||||
{ rank: 3, teamIndex: 3, athlete: '周敏', resultText: '13.36', resultValue: 13.36 },
|
||||
{ rank: 4, teamIndex: 6, athlete: '吴倩', resultText: '13.48', resultValue: 13.48 },
|
||||
{ rank: 5, teamIndex: 2, athlete: '郑爽', resultText: '13.55', resultValue: 13.55 },
|
||||
{ rank: 6, teamIndex: 7, athlete: '冯雪', resultText: '13.62', resultValue: 13.62 },
|
||||
],
|
||||
's',
|
||||
),
|
||||
},
|
||||
{
|
||||
eventId: 3,
|
||||
eventCode: 'E2001',
|
||||
eventName: '男子跳远',
|
||||
categoryId: 3,
|
||||
categoryName: '中年组',
|
||||
publishedAt: '2026-09-09T14:35:00+08:00',
|
||||
rows: buildRows(
|
||||
[
|
||||
{ rank: 1, teamIndex: 2, athlete: '黄涛', resultText: '6.42', resultValue: 6.42 },
|
||||
{ rank: 2, teamIndex: 5, athlete: '徐斌', resultText: '6.18', resultValue: 6.18 },
|
||||
{ rank: 3, teamIndex: 0, athlete: '马超', resultText: '6.05', resultValue: 6.05 },
|
||||
{ rank: 4, teamIndex: 7, athlete: '朱峰', resultText: '5.96', resultValue: 5.96 },
|
||||
{ rank: 5, teamIndex: 1, athlete: '胡军', resultText: '5.88', resultValue: 5.88 },
|
||||
{ rank: 6, teamIndex: 4, athlete: '林凯', resultText: '5.71', resultValue: 5.71 },
|
||||
],
|
||||
'm',
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const ATHLETE_SEEDS: readonly [string, number, number, number, number, number][] = [
|
||||
['张伟', 0, 42, 2, 1, 0],
|
||||
['李娜', 1, 38, 2, 0, 1],
|
||||
['李强', 2, 34, 1, 2, 0],
|
||||
['孙悦', 0, 30, 1, 1, 1],
|
||||
['黄涛', 2, 28, 1, 0, 2],
|
||||
['王磊', 1, 26, 1, 1, 0],
|
||||
['周敏', 3, 24, 1, 0, 1],
|
||||
['刘洋', 4, 22, 0, 2, 1],
|
||||
['徐斌', 5, 20, 1, 0, 1],
|
||||
['陈晨', 3, 18, 0, 1, 2],
|
||||
['马超', 0, 16, 0, 1, 1],
|
||||
['吴倩', 6, 14, 0, 0, 2],
|
||||
]
|
||||
|
||||
function buildAthletes(): AthleteStanding[] {
|
||||
return ATHLETE_SEEDS.map(([name, teamIndex, totalPoints, gold, silver, bronze], index) => {
|
||||
const item = MOCK_TEAMS[teamIndex]
|
||||
return {
|
||||
rank: index + 1,
|
||||
athleteId: index + 1,
|
||||
athleteName: name,
|
||||
gender: null,
|
||||
teamId: item?.id ?? 0,
|
||||
teamCode: item?.code ?? '',
|
||||
teamName: item?.name ?? '',
|
||||
shortName: item?.shortName ?? null,
|
||||
totalPoints,
|
||||
gold,
|
||||
silver,
|
||||
bronze,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createMockSnapshot(): SnapshotMessage {
|
||||
return {
|
||||
// version 固定 0 且不写入缓存:真实快照一到就能直接覆盖,不会因为版本号更大而被丢弃。
|
||||
version: 0,
|
||||
settings: {
|
||||
event: {
|
||||
name: '2026 职工运动会',
|
||||
subtitle: null,
|
||||
host: '示例组委会',
|
||||
logoPath: null,
|
||||
primaryColor: '#4fd8ff',
|
||||
},
|
||||
display: {
|
||||
background: null,
|
||||
logo: null,
|
||||
title: '2026 职工运动会',
|
||||
subtitle: '积分榜 · 示例数据',
|
||||
primaryColor: '#4fd8ff',
|
||||
rotateSeconds: 8,
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
},
|
||||
teams: MOCK_TEAMS,
|
||||
events: buildEvents(),
|
||||
standings: buildStandings(),
|
||||
records: [],
|
||||
latestResults: buildLatestResults(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockAthletes(): AthleteStanding[] {
|
||||
return buildAthletes()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SnapshotMessage } from '@/shared'
|
||||
import { isRecord, num, parseSnapshot } from './guards'
|
||||
|
||||
/**
|
||||
* 断线时继续出屏靠这份 localStorage 快照(06 第 8 节)。
|
||||
*
|
||||
* 只保留最近一份:现场大屏内存有限,历史版本没有回放价值;
|
||||
* 读到的内容仍要过一遍 parseSnapshot,避免旧版本格式把渲染炸掉。
|
||||
*/
|
||||
|
||||
const SNAPSHOT_KEY = 'scoreboard:display:snapshot'
|
||||
|
||||
export interface CachedSnapshot {
|
||||
version: number
|
||||
savedAt: number
|
||||
snapshot: SnapshotMessage
|
||||
}
|
||||
|
||||
export function readCachedSnapshot(): CachedSnapshot | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SNAPSHOT_KEY)
|
||||
if (raw === null) return null
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!isRecord(parsed)) return null
|
||||
const snapshot = parseSnapshot(parsed.snapshot)
|
||||
if (snapshot === null) return null
|
||||
return { version: snapshot.version, savedAt: num(parsed.savedAt), snapshot }
|
||||
} catch {
|
||||
// 隐私模式或配额异常时静默降级:缓存只是兜底,不能因此不出屏。
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedSnapshot(snapshot: SnapshotMessage): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SNAPSHOT_KEY,
|
||||
JSON.stringify({ version: snapshot.version, savedAt: Date.now(), snapshot }),
|
||||
)
|
||||
} catch {
|
||||
// 写失败同样只影响断线兜底,不阻断实时数据上屏。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { TeamBreakdown } from '@/shared'
|
||||
|
||||
/**
|
||||
* 大屏页面清单。顺序即整页轮播顺序(公告是手动触发的页,不参与轮播)。
|
||||
*/
|
||||
export const DISPLAY_PAGES = [
|
||||
{ key: 'standings', title: '总积分榜' },
|
||||
{ key: 'team-medals', title: '团体奖牌榜' },
|
||||
{ key: 'athlete-standings', title: '个人积分奖牌榜' },
|
||||
{ key: 'event-results', title: '分项成绩' },
|
||||
{ key: 'announcement', title: '公告' },
|
||||
] as const
|
||||
|
||||
export type DisplayPageKey = (typeof DISPLAY_PAGES)[number]['key']
|
||||
|
||||
export const PAGE_TITLES: Record<DisplayPageKey, string> = {
|
||||
standings: '总积分榜',
|
||||
'team-medals': '团体奖牌榜',
|
||||
'athlete-standings': '个人积分奖牌榜',
|
||||
'event-results': '分项成绩',
|
||||
announcement: '公告',
|
||||
}
|
||||
|
||||
/** 管理端 `goto` 下发的页名不保证与内部 key 一致,这里统一归一化。 */
|
||||
const PAGE_ALIASES: Record<string, DisplayPageKey> = {
|
||||
standings: 'standings',
|
||||
total: 'standings',
|
||||
points: 'standings',
|
||||
积分榜: 'standings',
|
||||
总积分榜: 'standings',
|
||||
'team-medals': 'team-medals',
|
||||
medals: 'team-medals',
|
||||
medal: 'team-medals',
|
||||
奖牌榜: 'team-medals',
|
||||
团体奖牌榜: 'team-medals',
|
||||
'athlete-standings': 'athlete-standings',
|
||||
athletes: 'athlete-standings',
|
||||
individual: 'athlete-standings',
|
||||
个人积分奖牌榜: 'athlete-standings',
|
||||
个人榜: 'athlete-standings',
|
||||
'event-results': 'event-results',
|
||||
results: 'event-results',
|
||||
分项成绩: 'event-results',
|
||||
成绩: 'event-results',
|
||||
announcement: 'announcement',
|
||||
announce: 'announcement',
|
||||
公告: 'announcement',
|
||||
}
|
||||
|
||||
export function normalizePage(value: string | null | undefined): DisplayPageKey | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const key = value.trim()
|
||||
if (key === '') return null
|
||||
return PAGE_ALIASES[key] ?? PAGE_ALIASES[key.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export interface DisplayUrlOptions {
|
||||
page: DisplayPageKey | null
|
||||
/** true = 主区域锁页不轮播(默认)。 */
|
||||
locked: boolean
|
||||
screen: string | null
|
||||
instance: string | null
|
||||
/** 分项成绩页展示的已完成项目数,默认 3。 */
|
||||
resultsCount: number
|
||||
}
|
||||
|
||||
export interface DetailOverlayState {
|
||||
title: string
|
||||
detail: TeamBreakdown | null
|
||||
autoCloseSeconds: number
|
||||
openedAt: number
|
||||
/** 管理端只给了 teamId、明细还在拉取时为 true。 */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export interface AnnouncementState {
|
||||
text: string
|
||||
seconds: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
/** live = 实时/接口数据;cache = 断线时回放的本地快照;mock = 后端未就绪的示例数据。 */
|
||||
export type DisplaySource = 'live' | 'cache' | 'mock'
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 「减少动态效果」系统偏好(06 第 7 节:所有动画在该偏好下自动关闭)。
|
||||
*
|
||||
* 做成模块级单例:大屏上几十个数字组件各自 matchMedia 会白白多出几十个监听。
|
||||
*/
|
||||
const reduced = ref(false)
|
||||
let initialized = false
|
||||
|
||||
function init(): void {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
reduced.value = query.matches
|
||||
query.addEventListener('change', (event) => {
|
||||
reduced.value = event.matches
|
||||
})
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): Ref<boolean> {
|
||||
init()
|
||||
return reduced
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { RealtimeClient, createApiClient } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
Change,
|
||||
DisplayCommand,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
EventSettings,
|
||||
RealtimeStatus,
|
||||
RecordEntry,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import {
|
||||
isRecord,
|
||||
list,
|
||||
numOrNull,
|
||||
parseAthleteStandings,
|
||||
parseEvent,
|
||||
parseEventResultGroups,
|
||||
parseSettings,
|
||||
parseStandingRow,
|
||||
parseStandingRows,
|
||||
parseTeam,
|
||||
parseTeamBreakdown,
|
||||
} from './guards'
|
||||
import { createMockAthletes, createMockSnapshot } from './mock'
|
||||
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
|
||||
import { DISPLAY_PAGES, normalizePage } from './types'
|
||||
import type {
|
||||
AnnouncementState,
|
||||
DetailOverlayState,
|
||||
DisplayPageKey,
|
||||
DisplaySource,
|
||||
DisplayUrlOptions,
|
||||
} from './types'
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
/** 整页轮播间隔(06 第 4 节:后台可切换为整页轮播,每页 20s)。 */
|
||||
const PAGE_ROTATE_MS = 20_000
|
||||
/** 名次升降箭头保留时长(06 第 7 节:2s 后淡出)。 */
|
||||
const RANK_DELTA_MS = 2_000
|
||||
|
||||
const api = createApiClient()
|
||||
const reducedMotion = prefersReducedMotion()
|
||||
|
||||
const source = ref<DisplaySource>('live')
|
||||
const status = ref<RealtimeStatus>('idle')
|
||||
const version = ref<number | null>(null)
|
||||
const settings = ref<EventSettings | null>(null)
|
||||
const teams = ref<Team[]>([])
|
||||
const events = ref<Event[]>([])
|
||||
const standings = ref<StandingRow[]>([])
|
||||
const athletes = ref<AthleteStanding[]>([])
|
||||
const latestResults = ref<EventResultGroup[]>([])
|
||||
const records = ref<RecordEntry[]>([])
|
||||
const rankDelta = ref<Record<number, number>>({})
|
||||
const page = ref<DisplayPageKey>('standings')
|
||||
const locked = ref(true)
|
||||
const options = ref<DisplayUrlOptions>({
|
||||
page: null,
|
||||
locked: true,
|
||||
screen: null,
|
||||
instance: null,
|
||||
resultsCount: 3,
|
||||
})
|
||||
const detail = ref<DetailOverlayState | null>(null)
|
||||
const announcement = ref<AnnouncementState | null>(null)
|
||||
const booted = ref(false)
|
||||
|
||||
let client: RealtimeClient | null = null
|
||||
let rankTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let detailTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pageTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true)
|
||||
|
||||
/** 参与整页轮播的页;公告是手动触发的页,个人榜按配置可隐藏。 */
|
||||
const rotationPages = computed<DisplayPageKey[]>(() =>
|
||||
DISPLAY_PAGES.map((item) => item.key).filter(
|
||||
(key) => key !== 'announcement' && (key !== 'athlete-standings' || showAthleteBoard.value),
|
||||
),
|
||||
)
|
||||
|
||||
function compareStanding(a: StandingRow, b: StandingRow): number {
|
||||
return a.rank - b.rank || b.totalPoints - a.totalPoints
|
||||
}
|
||||
|
||||
function clearTimer(timer: ReturnType<typeof setTimeout> | null): null {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据落地
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 用新的积分榜替换本地榜单,并算出相对上一次的名次变化。
|
||||
*
|
||||
* 服务端给的 rankChange 优先;没有基准(首屏 / 刚换页)时用本地前后对比兜底,
|
||||
* 这样断线重连后即使服务端没带 rankChange,箭头也不会凭空消失。
|
||||
*/
|
||||
function applyStandings(rows: StandingRow[]): void {
|
||||
const previous = new Map(standings.value.map((row) => [row.teamId, row.rank]))
|
||||
const deltas: Record<number, number> = {}
|
||||
const next = rows.map((row) => {
|
||||
const before = previous.get(row.teamId)
|
||||
const local = before === undefined ? null : before - row.rank
|
||||
const delta = row.rankChange ?? local
|
||||
if (delta !== null && delta !== 0) deltas[row.teamId] = delta
|
||||
return { ...row, rankChange: delta }
|
||||
})
|
||||
|
||||
standings.value = next.sort(compareStanding)
|
||||
|
||||
if (Object.keys(deltas).length === 0) return
|
||||
rankDelta.value = deltas
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
rankTimer = setTimeout(() => {
|
||||
rankDelta.value = {}
|
||||
rankTimer = null
|
||||
}, RANK_DELTA_MS)
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
|
||||
settings.value = snapshot.settings
|
||||
teams.value = snapshot.teams
|
||||
events.value = snapshot.events
|
||||
latestResults.value = snapshot.latestResults
|
||||
records.value = snapshot.records
|
||||
version.value = snapshot.version
|
||||
applyStandings(snapshot.standings)
|
||||
source.value = kind
|
||||
}
|
||||
|
||||
function mergeById<T extends { id: number }>(
|
||||
current: T[],
|
||||
change: Change,
|
||||
parse: (value: unknown) => T,
|
||||
): T[] {
|
||||
if (change.action === 'replace') return list(change.data).map(parse)
|
||||
|
||||
if (change.action === 'void') {
|
||||
const id = change.id ?? (isRecord(change.data) ? numOrNull(change.data.id) : null)
|
||||
return id === null ? current : current.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
const item = parse(change.data)
|
||||
return [...current.filter((entry) => entry.id !== item.id), item]
|
||||
}
|
||||
|
||||
function mergeStanding(row: StandingRow): void {
|
||||
applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row])
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用增量变更。
|
||||
*
|
||||
* 只有榜单类实体能在客户端可靠合并;其余实体(如逐条成绩)合并规则在服务端,
|
||||
* 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。
|
||||
*/
|
||||
function applyChanges(changes: Change[]): void {
|
||||
let resyncNeeded = false
|
||||
|
||||
for (const change of changes) {
|
||||
switch (change.entity) {
|
||||
case 'standings':
|
||||
if (change.action === 'replace') applyStandings(parseStandingRows(change.data))
|
||||
else if (change.action === 'upsert') mergeStanding(parseStandingRow(change.data))
|
||||
break
|
||||
case 'athleteStandings':
|
||||
case 'athlete_standings':
|
||||
if (change.action === 'replace') athletes.value = parseAthleteStandings(change.data)
|
||||
break
|
||||
case 'latestResults':
|
||||
case 'eventResults':
|
||||
if (change.action === 'replace') latestResults.value = parseEventResultGroups(change.data)
|
||||
break
|
||||
case 'settings':
|
||||
if (change.action !== 'void') settings.value = parseSettings(change.data)
|
||||
break
|
||||
case 'teams':
|
||||
teams.value = mergeById(teams.value, change, parseTeam)
|
||||
break
|
||||
case 'events':
|
||||
events.value = mergeById(events.value, change, parseEvent)
|
||||
break
|
||||
default:
|
||||
resyncNeeded = true
|
||||
}
|
||||
}
|
||||
|
||||
if (resyncNeeded) client?.requestResync()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 断线兜底与启动
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hydrateFromCache(): void {
|
||||
const cached = readCachedSnapshot()
|
||||
if (cached === null) return
|
||||
applySnapshot(cached.snapshot, 'cache')
|
||||
}
|
||||
|
||||
/**
|
||||
* REST 兜底:快照不含个人榜,所以个人榜必须单独取;
|
||||
* 但快照比 REST 权威(REST 没有 version),已收到快照时不用 REST 覆盖。
|
||||
*/
|
||||
async function loadRest(): Promise<boolean> {
|
||||
const hasSnapshot = version.value !== null
|
||||
const [settingsResult, standingsResult, athletesResult] = await Promise.allSettled([
|
||||
api.settings.get(),
|
||||
api.standings.teams(),
|
||||
api.standings.athletes(),
|
||||
])
|
||||
|
||||
let ok = false
|
||||
if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
|
||||
settings.value = settingsResult.value
|
||||
ok = true
|
||||
}
|
||||
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
|
||||
applyStandings(standingsResult.value)
|
||||
ok = true
|
||||
}
|
||||
if (athletesResult.status === 'fulfilled') {
|
||||
athletes.value = athletesResult.value
|
||||
ok = true
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
function activateMock(): void {
|
||||
const snapshot = createMockSnapshot()
|
||||
settings.value = snapshot.settings
|
||||
teams.value = snapshot.teams
|
||||
events.value = snapshot.events
|
||||
latestResults.value = snapshot.latestResults
|
||||
records.value = snapshot.records
|
||||
athletes.value = createMockAthletes()
|
||||
standings.value = snapshot.standings
|
||||
source.value = 'mock'
|
||||
|
||||
// 示例数据也把升降箭头演示出来,方便验收动效。
|
||||
const deltas: Record<number, number> = {}
|
||||
for (const row of snapshot.standings) {
|
||||
if (row.rankChange !== null && row.rankChange !== 0) deltas[row.teamId] = row.rankChange
|
||||
}
|
||||
rankDelta.value = deltas
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
rankTimer = setTimeout(() => {
|
||||
rankDelta.value = {}
|
||||
rankTimer = null
|
||||
}, RANK_DELTA_MS)
|
||||
}
|
||||
|
||||
function readUrlOptions(): DisplayUrlOptions {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const lockParam = params.get('lock')
|
||||
return {
|
||||
page: normalizePage(params.get('page')),
|
||||
// 主区域默认锁定总积分榜,只有显式 lock=0 才开整页轮播。
|
||||
locked: lockParam !== '0' && lockParam !== 'false',
|
||||
screen: params.get('screen'),
|
||||
instance: params.get('instance'),
|
||||
resultsCount: clampInt(params.get('n'), 1, 6, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function clampInt(value: string | null, min: number, max: number, fallback: number): number {
|
||||
if (value === null) return fallback
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 轮播
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stopPageRotation(): void {
|
||||
if (pageTimer !== null) {
|
||||
clearInterval(pageTimer)
|
||||
pageTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPageRotation(): void {
|
||||
stopPageRotation()
|
||||
if (locked.value || reducedMotion.value) return
|
||||
const order = rotationPages.value
|
||||
if (order.length < 2) return
|
||||
|
||||
pageTimer = setInterval(() => {
|
||||
const current = order.indexOf(page.value)
|
||||
const next = order[(current + 1) % order.length]
|
||||
if (next !== undefined) page.value = next
|
||||
}, PAGE_ROTATE_MS)
|
||||
}
|
||||
|
||||
function setPage(next: DisplayPageKey): void {
|
||||
page.value = next
|
||||
}
|
||||
|
||||
function setLocked(value: boolean): void {
|
||||
locked.value = value
|
||||
if (value) stopPageRotation()
|
||||
else startPageRotation()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 覆盖层与公告
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function clearDetailTimer(): void {
|
||||
detailTimer = clearTimer(detailTimer)
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
clearDetailTimer()
|
||||
detail.value = null
|
||||
if (!locked.value) startPageRotation()
|
||||
}
|
||||
|
||||
async function openDetail(command: DisplayCommand): Promise<void> {
|
||||
const payload = command.payload
|
||||
const provided = payload.detail === undefined ? null : parseTeamBreakdown(payload.detail)
|
||||
const teamId = typeof payload.teamId === 'number' ? payload.teamId : null
|
||||
const openedAt = Date.now()
|
||||
|
||||
detail.value = {
|
||||
title: payload.title ?? provided?.teamName ?? '积分明细',
|
||||
detail: provided,
|
||||
autoCloseSeconds: payload.autoCloseSeconds ?? 60,
|
||||
openedAt,
|
||||
loading: provided === null && teamId !== null,
|
||||
}
|
||||
// 明细覆盖期间暂停轮播(06 第 5 节)。
|
||||
stopPageRotation()
|
||||
clearDetailTimer()
|
||||
if (detail.value.autoCloseSeconds > 0) {
|
||||
detailTimer = setTimeout(closeDetail, detail.value.autoCloseSeconds * 1000)
|
||||
}
|
||||
|
||||
if (!detail.value.loading || teamId === null) return
|
||||
try {
|
||||
const breakdown = parseTeamBreakdown(await api.standings.breakdown(teamId))
|
||||
// 请求期间可能已被 hide-detail 或自动关闭,用 openedAt 判断这份响应还属于当前覆盖层。
|
||||
if (detail.value !== null && detail.value.openedAt === openedAt) {
|
||||
detail.value.detail = breakdown
|
||||
detail.value.loading = false
|
||||
}
|
||||
} catch {
|
||||
if (detail.value !== null && detail.value.openedAt === openedAt) detail.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function announce(text: string, seconds: number): void {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
|
||||
announcement.value = { text: trimmed, seconds, startedAt: Date.now() }
|
||||
const previous = page.value
|
||||
page.value = 'announcement'
|
||||
|
||||
announceTimer = clearTimer(announceTimer)
|
||||
if (seconds > 0) {
|
||||
announceTimer = setTimeout(() => {
|
||||
announceTimer = null
|
||||
if (page.value === 'announcement') page.value = previous
|
||||
}, seconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommand(command: DisplayCommand): void {
|
||||
switch (command.action) {
|
||||
case 'goto': {
|
||||
const target = normalizePage(command.payload.page)
|
||||
if (target !== null) {
|
||||
closeDetail()
|
||||
page.value = target
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'lock':
|
||||
setLocked(true)
|
||||
break
|
||||
case 'unlock':
|
||||
setLocked(false)
|
||||
break
|
||||
case 'reload':
|
||||
window.location.reload()
|
||||
break
|
||||
case 'announce':
|
||||
announce(command.payload.text ?? '', command.payload.seconds ?? 0)
|
||||
break
|
||||
case 'show-detail':
|
||||
void openDetail(command)
|
||||
break
|
||||
case 'hide-detail':
|
||||
closeDetail()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 生命周期
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function connect(): void {
|
||||
if (client !== null) return
|
||||
client = new RealtimeClient({
|
||||
role: 'display',
|
||||
displayKey: options.value.instance ?? options.value.screen ?? undefined,
|
||||
initialVersion: version.value,
|
||||
onSnapshot: (message) => {
|
||||
applySnapshot(message, 'live')
|
||||
writeCachedSnapshot(message)
|
||||
},
|
||||
onCatchup: (message) => {
|
||||
applyChanges(message.changes)
|
||||
},
|
||||
onChange: (message) => {
|
||||
applyChanges(message.changes)
|
||||
},
|
||||
onCommand: handleCommand,
|
||||
onStatusChange: (next) => {
|
||||
status.value = next
|
||||
},
|
||||
onVersionChange: (next) => {
|
||||
version.value = next
|
||||
},
|
||||
})
|
||||
client.connect()
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (booted.value) return
|
||||
booted.value = true
|
||||
|
||||
const parsed = readUrlOptions()
|
||||
options.value = parsed
|
||||
if (parsed.page !== null) page.value = parsed.page
|
||||
locked.value = parsed.locked
|
||||
|
||||
hydrateFromCache()
|
||||
connect()
|
||||
|
||||
const restOk = await loadRest()
|
||||
if (!restOk && version.value === null) activateMock()
|
||||
|
||||
if (!locked.value) startPageRotation()
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
stopPageRotation()
|
||||
clearDetailTimer()
|
||||
rankTimer = clearTimer(rankTimer)
|
||||
announceTimer = clearTimer(announceTimer)
|
||||
client?.close()
|
||||
client = null
|
||||
booted.value = false
|
||||
}
|
||||
|
||||
function rankDeltaFor(teamId: number): number | null {
|
||||
return rankDelta.value[teamId] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏只有这一份状态:多块屏各自是独立窗口/标签页,不需要跨实例共享,
|
||||
* 所以用模块级单例而不是 Pinia,少一层依赖。
|
||||
*/
|
||||
export function useScoreboard() {
|
||||
return {
|
||||
source,
|
||||
status,
|
||||
version,
|
||||
settings,
|
||||
teams,
|
||||
events,
|
||||
standings,
|
||||
athletes,
|
||||
latestResults,
|
||||
records,
|
||||
rankDelta,
|
||||
page,
|
||||
locked,
|
||||
options,
|
||||
detail,
|
||||
announcement,
|
||||
booted,
|
||||
rotationPages,
|
||||
bootstrap,
|
||||
dispose,
|
||||
setPage,
|
||||
setLocked,
|
||||
startPageRotation,
|
||||
stopPageRotation,
|
||||
closeDetail,
|
||||
rankDeltaFor,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
export const DESIGN_WIDTH = 1920
|
||||
export const DESIGN_HEIGHT = 1080
|
||||
|
||||
/**
|
||||
* 1920×1080 设计基准 + `transform: scale()` 等比适配(06 第 3 节)。
|
||||
*
|
||||
* 只按「较小的一边」缩放并居中,非 16:9 窗口留黑边而不拉伸;
|
||||
* 不写死设备像素,1080p / 4K / 系统缩放都能正确铺满。
|
||||
*/
|
||||
export function useStageScale() {
|
||||
const scale = ref(1)
|
||||
const offsetX = ref(0)
|
||||
const offsetY = ref(0)
|
||||
|
||||
function fit(): void {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
const next = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT)
|
||||
scale.value = next
|
||||
offsetX.value = (width - DESIGN_WIDTH * next) / 2
|
||||
offsetY.value = (height - DESIGN_HEIGHT * next) / 2
|
||||
}
|
||||
|
||||
fit()
|
||||
// display-metrics-changed(Electron 改分辨率/缩放)在页面里同样表现为 resize。
|
||||
window.addEventListener('resize', fit)
|
||||
window.addEventListener('orientationchange', fit)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', fit)
|
||||
window.removeEventListener('orientationchange', fit)
|
||||
})
|
||||
|
||||
return { scale, offsetX, offsetY, fit }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './fonts.css';
|
||||
|
||||
/* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */
|
||||
:root {
|
||||
--bg: #0b1e3a;
|
||||
@@ -10,6 +12,9 @@
|
||||
|
||||
--design-w: 1920px;
|
||||
--design-h: 1080px;
|
||||
|
||||
/* 全局默认字体;各端可通过覆盖 --font-ui 切换(字体清单见 vendor/fonts/manifest.json) */
|
||||
--font-ui: var(--font-noto-sans);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -28,8 +33,7 @@ body,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
'Noto Sans SC', 'Source Han Sans SC', 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif;
|
||||
font-family: var(--font-ui, 'Noto Sans SC', 'Source Han Sans SC', system-ui, sans-serif);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 字体声明 —— 离线部署,字体已 vendor 进仓库(vendor/fonts/),禁止任何 CDN。
|
||||
*
|
||||
* 字符集:ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号(7553 字符),
|
||||
* 子集化与体积明细见 vendor/fonts/manifest.json 与 vendor/fonts/README.md。
|
||||
*
|
||||
* 引用方式:在全局样式入口 `@import './fonts.css';`(本文件不自动生效)。
|
||||
* 需要新增字体:改 vendor/fonts/tools/fonts.conf 后重跑 subset-fonts.sh,
|
||||
* 再在本文件补对应的 @font-face 与 --font-<id> 变量。
|
||||
*/
|
||||
|
||||
/* ---------- Noto Sans SC(思源黑体同源,无衬线,大屏正文默认) ---------- */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans SC';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/noto-sans-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans SC';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/noto-sans-bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ---------- Noto Serif SC(衬线,用于标题/仪式感文案) ---------- */
|
||||
@font-face {
|
||||
font-family: 'Noto Serif SC';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/noto-serif-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Serif SC';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/noto-serif-bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ---------- MiSans(小米,无衬线;授权见 vendor/fonts/README.md) ---------- */
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/misans-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/misans-bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ---------- Source Han Sans CN(思源黑体,无衬线) ---------- */
|
||||
@font-face {
|
||||
font-family: 'Source Han Sans CN';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/source-han-sans-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Source Han Sans CN';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('../../../../vendor/fonts/source-han-sans-bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ---------- 字体令牌:大屏 / 管理端按需切换 ---------- */
|
||||
:root {
|
||||
--font-noto-sans: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-noto-serif: 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||
--font-misans: 'MiSans', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-source-han-sans:
|
||||
'Source Han Sans CN', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
}
|
||||
Reference in new issue
Block a user