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

+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),
}
})