feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CurrentUser } from '@/shared'
|
||||
import { adminApi, mockMode, setUnauthorizedHandler } from '../api/client'
|
||||
|
||||
/**
|
||||
* 会话状态。
|
||||
*
|
||||
* 未登录时不是「拦截」,而是降级为 guest 只读视图(07 第 1 节):
|
||||
* 现场录入员换班、或服务端会话过期时,管理员仍能查看积分与成绩。
|
||||
*/
|
||||
export const useAuthStore = defineStore('admin-auth', () => {
|
||||
const user = ref<CurrentUser | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
|
||||
const initialized = ref(false)
|
||||
|
||||
const isGuest = computed(() => user.value === null)
|
||||
const readOnly = computed(() => user.value === null)
|
||||
const displayName = computed(() => user.value?.displayName ?? '访客')
|
||||
const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户')))
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (initialized.value) return
|
||||
initialized.value = true
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
user.value = await adminApi.me()
|
||||
} catch {
|
||||
// 未登录 / 后端未就绪都走只读访客,不把用户挡在门外。
|
||||
user.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string): Promise<boolean> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
user.value = await adminApi.login(username, password)
|
||||
initialized.value = true
|
||||
return true
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '登录失败'
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await adminApi.logout()
|
||||
} catch {
|
||||
// 登出失败也要清本地态,否则用户会卡在已登录界面。
|
||||
}
|
||||
user.value = null
|
||||
}
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
user.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isGuest,
|
||||
readOnly,
|
||||
displayName,
|
||||
roleLabel,
|
||||
mockMode,
|
||||
load,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { RealtimeClient, type RealtimeStatus, type SnapshotMessage } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
Event,
|
||||
EventSettings,
|
||||
ScoringProfile,
|
||||
Sport,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import { adminApi, mockMode, mockReason } from '../api/client'
|
||||
|
||||
/**
|
||||
* 赛事全局数据(单位 / 项目 / 积分榜)。
|
||||
*
|
||||
* 管理端断网时必须禁止写操作(05 第 4 节),所以连接状态放在这里,
|
||||
* 各页面统一用 `canWrite` 判断按钮是否可点。
|
||||
*/
|
||||
export const useEventStore = defineStore('admin-event', () => {
|
||||
const settings = ref<EventSettings | null>(null)
|
||||
const teams = ref<Team[]>([])
|
||||
const sports = ref<Sport[]>([])
|
||||
const events = ref<Event[]>([])
|
||||
const profiles = ref<ScoringProfile[]>([])
|
||||
const standings = ref<StandingRow[]>([])
|
||||
const athleteStandings = ref<AthleteStanding[]>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const lastRefreshAt = ref<string | null>(null)
|
||||
|
||||
const realtimeStatus = ref<RealtimeStatus>('idle')
|
||||
const serverVersion = ref<number | null>(null)
|
||||
const serverTime = ref<string | null>(null)
|
||||
const serverHealth = ref<string | null>(null)
|
||||
|
||||
let realtime: RealtimeClient | null = null
|
||||
|
||||
const isMock = computed(() => mockMode.value)
|
||||
const mockLabel = computed(() =>
|
||||
mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null,
|
||||
)
|
||||
const online = computed(() => realtimeStatus.value === 'online')
|
||||
/**
|
||||
* 写操作门槛:真实后端需实时在线;示例数据模式允许本地写入以便走通交互。
|
||||
* 登录态由 auth store 叠加判断,这里只管连接。
|
||||
*/
|
||||
const canWrite = computed(() => mockMode.value || realtimeStatus.value === 'online')
|
||||
|
||||
function applySnapshot(snapshot: SnapshotMessage): void {
|
||||
settings.value = snapshot.settings
|
||||
teams.value = snapshot.teams
|
||||
events.value = snapshot.events
|
||||
standings.value = snapshot.standings
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await adminApi.bootstrap()
|
||||
settings.value = data.settings
|
||||
teams.value = data.teams
|
||||
sports.value = data.sports
|
||||
events.value = data.events
|
||||
profiles.value = data.profiles
|
||||
standings.value = data.standings
|
||||
athleteStandings.value = data.athleteStandings
|
||||
loaded.value = true
|
||||
lastRefreshAt.value = new Date().toISOString()
|
||||
if (!mockMode.value) startRealtime()
|
||||
void checkHealth()
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHealth(): Promise<void> {
|
||||
try {
|
||||
const info = await adminApi.health()
|
||||
serverHealth.value = info.version
|
||||
} catch {
|
||||
serverHealth.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function startRealtime(): void {
|
||||
if (realtime !== null) return
|
||||
realtime = new RealtimeClient({
|
||||
role: 'admin',
|
||||
onSnapshot: applySnapshot,
|
||||
onChange: () => {
|
||||
// 增量只用于触发重拉,管理端不自己合并积分榜,避免与服务端算出的真值分叉。
|
||||
void refreshStandings()
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
realtimeStatus.value = status
|
||||
},
|
||||
onVersionChange: (version) => {
|
||||
serverVersion.value = version
|
||||
},
|
||||
onError: () => {
|
||||
// 连接错误由 onStatusChange 统一呈现,这里不弹 toast,避免刷屏。
|
||||
},
|
||||
})
|
||||
realtime.connect()
|
||||
}
|
||||
|
||||
function stopRealtime(): void {
|
||||
realtime?.close()
|
||||
realtime = null
|
||||
realtimeStatus.value = 'idle'
|
||||
}
|
||||
|
||||
async function refreshStandings(): Promise<void> {
|
||||
try {
|
||||
const [next, athletes] = await Promise.all([
|
||||
adminApi.standings(),
|
||||
adminApi.athleteStandings(),
|
||||
])
|
||||
standings.value = next
|
||||
athleteStandings.value = athletes
|
||||
lastRefreshAt.value = new Date().toISOString()
|
||||
} catch (caught) {
|
||||
error.value = caught instanceof Error ? caught.message : '刷新积分榜失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEvents(): Promise<void> {
|
||||
try {
|
||||
events.value = await adminApi.client.events.list()
|
||||
} catch {
|
||||
// 项目列表刷新失败保留旧数据,页面仍有内容可操作。
|
||||
}
|
||||
}
|
||||
|
||||
function eventById(id: number): Event | undefined {
|
||||
return events.value.find((item) => item.id === id)
|
||||
}
|
||||
|
||||
function teamById(id: number): Team | undefined {
|
||||
return teams.value.find((item) => item.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
teams,
|
||||
sports,
|
||||
events,
|
||||
profiles,
|
||||
standings,
|
||||
athleteStandings,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
lastRefreshAt,
|
||||
realtimeStatus,
|
||||
serverVersion,
|
||||
serverTime,
|
||||
serverHealth,
|
||||
isMock,
|
||||
mockLabel,
|
||||
online,
|
||||
canWrite,
|
||||
load,
|
||||
checkHealth,
|
||||
refreshStandings,
|
||||
refreshEvents,
|
||||
eventById,
|
||||
teamById,
|
||||
stopRealtime,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastKind = 'success' | 'warning' | 'error' | 'info'
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export const useToastStore = defineStore('admin-toast', () => {
|
||||
const items = ref<ToastItem[]>([])
|
||||
|
||||
function dismiss(id: number): void {
|
||||
items.value = items.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
function push(kind: ToastKind, message: string, detail: string | null = null, ttlMs = 5000): number {
|
||||
const id = nextId++
|
||||
items.value = [...items.value, { id, kind, message, detail }]
|
||||
if (ttlMs > 0) window.setTimeout(() => dismiss(id), ttlMs)
|
||||
return id
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
dismiss,
|
||||
push,
|
||||
success: (message: string, detail: string | null = null) => push('success', message, detail),
|
||||
warning: (message: string, detail: string | null = null) => push('warning', message, detail),
|
||||
error: (message: string, detail: string | null = null) => push('error', message, detail, 8000),
|
||||
info: (message: string, detail: string | null = null) => push('info', message, detail),
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user