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

@@ -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