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,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>
|
||||
Reference in new issue
Block a user