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:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+2 -60
View File
@@ -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>
+172
View File
@@ -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 }
+731
View File
@@ -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()
+176
View File
@@ -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>
+23
View File
@@ -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>
+30
View File
@@ -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>
+51
View File
@@ -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>
+32
View File
@@ -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
+328
View File
@@ -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>
+4 -1
View File
@@ -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')
+258
View File
@@ -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>
+358
View File
@@ -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
+152
View File
@@ -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>
+159
View File
@@ -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>
+465
View File
@@ -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>
+106
View File
@@ -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} · 管理面板`
})
+80
View File
@@ -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,
}
})
+178
View File
@@ -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,
}
})
+38
View File
@@ -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),
}
})
+105
View File
@@ -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;
}