344 lines
10 KiB
Vue
344 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import PageHeader from '../components/PageHeader.vue'
|
||
import { adminApi } from '../api/client'
|
||
import { useEventStore } from '../stores/event'
|
||
|
||
const data = useEventStore()
|
||
|
||
const SYSTEM_NAME = '运动会积分榜系统'
|
||
const SYSTEM_EN = 'Scoreboard'
|
||
// /api/health 只回 version,没有构建时间字段;构建期常量要改 vite.config,本页按约定显示 dev
|
||
const BUILD_TIME = 'dev'
|
||
|
||
const version = ref<string | null>(null)
|
||
const versionChecked = ref(false)
|
||
|
||
const versionText = computed(() => {
|
||
if (version.value !== null) return version.value
|
||
return versionChecked.value ? '未知(/api/health 不可达)' : '读取中…'
|
||
})
|
||
|
||
interface InfoItem {
|
||
name: string
|
||
usage: string
|
||
license: string
|
||
note?: string
|
||
}
|
||
|
||
const stack: InfoItem[] = [
|
||
{ name: 'Go 1.26', usage: '服务端、实时协议、静态资源内嵌', license: 'MIT(本项目)' },
|
||
{ name: 'Vue 3 + TypeScript', usage: '管理端与大屏前端', license: 'MIT' },
|
||
{ name: 'Vite', usage: '前端构建与开发服务', license: 'MIT' },
|
||
{ name: 'Pinia + Vue Router', usage: '管理端状态与路由', license: 'MIT' },
|
||
{ name: 'Electron', usage: 'Scoreboard Console 桌面宿主', license: 'MIT' },
|
||
{ name: 'MIUIx', usage: '管理端界面风格(vendor 内置,不走 CDN)', license: '随仓库分发的样式参考实现' },
|
||
]
|
||
|
||
const fonts: InfoItem[] = [
|
||
{ name: 'Noto Sans SC', usage: '默认界面字体', license: 'OFL-1.1' },
|
||
{ name: 'Noto Serif SC', usage: '标题衬线字体', license: 'OFL-1.1' },
|
||
{ name: 'Source Han Sans CN(思源黑体)', usage: '备用无衬线字体', license: 'OFL-1.1' },
|
||
{
|
||
name: 'MiSans',
|
||
usage: '可选界面字体',
|
||
license: 'MiSans Font License',
|
||
note: 'MiSans 仅限内部赛事使用,不得对外分发',
|
||
},
|
||
]
|
||
|
||
interface ShortcutItem {
|
||
keys: string[]
|
||
desc: string
|
||
}
|
||
|
||
const entryShortcuts: ShortcutItem[] = [
|
||
{ keys: ['Tab'], desc: '跳到下一个单元格(浏览器原生焦点顺序)' },
|
||
{ keys: ['Enter'], desc: '在成绩格之间移动' },
|
||
{ keys: ['Ctrl', 'Enter'], desc: '保存草稿' },
|
||
{ keys: ['Alt', 'Enter'], desc: '发布当前项目成绩' },
|
||
]
|
||
|
||
const displayShortcuts: ShortcutItem[] = [
|
||
{ keys: ['F11'], desc: '单屏窗口化模式下切换全屏;多屏 kiosk 不响应' },
|
||
]
|
||
|
||
const eventName = computed(() => data.settings?.event.name.trim() || '未配置')
|
||
|
||
const port = computed(() => {
|
||
if (window.location.port !== '') return window.location.port
|
||
return window.location.protocol === 'https:' ? '443' : '80'
|
||
})
|
||
|
||
const origin = computed(() => window.location.origin)
|
||
|
||
// 内部署名由构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串
|
||
const internalSignature = ref('')
|
||
|
||
onMounted(async () => {
|
||
// 直接从 /api/health 取版本;不走 adminApi.health 的示例数据兜底,避免把 mock 当成真实版本
|
||
try {
|
||
const info = await adminApi.client.health()
|
||
version.value = info.version
|
||
} catch {
|
||
version.value = null
|
||
} finally {
|
||
versionChecked.value = true
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/system/about', { credentials: 'include' })
|
||
if (res.ok) {
|
||
const body = (await res.json()) as { ok: boolean; data?: { internalSignature?: string } }
|
||
internalSignature.value = body.data?.internalSignature ?? ''
|
||
}
|
||
} catch {
|
||
// 未登录 / 接口不可达都按「没有署名」处理,不打扰用户
|
||
}
|
||
|
||
if (!data.loaded) void data.load()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="about-page">
|
||
<PageHeader title="关于" subtitle="系统信息、技术栈与部署参数" />
|
||
|
||
<div style="display: flex; align-items: center; gap: 14px; margin-bottom: 16px">
|
||
<img class="app-icon" src="/scoreboard.png" alt="" width="56" height="56" />
|
||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||
<strong style="font-size: 18px">{{ SYSTEM_NAME }}</strong>
|
||
<span style="font-size: 12px; color: var(--mx-t3)">{{ SYSTEM_EN }} · v{{ versionText }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="internalSignature" class="mx-card" style="margin-bottom: 16px">
|
||
<div class="mx-card-body" style="display: flex; flex-direction: column; gap: 4px">
|
||
<span style="font-size: 12px; color: var(--mx-t3)">内部署名</span>
|
||
<strong style="font-size: 15px">{{ internalSignature }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">基本信息</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<dl class="kv">
|
||
<div>
|
||
<dt>系统名称</dt>
|
||
<dd>
|
||
{{ SYSTEM_NAME }}
|
||
<span class="en">{{ SYSTEM_EN }}</span>
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>版本号</dt>
|
||
<dd class="mono">{{ versionText }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>构建时间</dt>
|
||
<dd class="mono">{{ BUILD_TIME }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>运行模式</dt>
|
||
<dd>{{ data.isMock ? '示例数据' : '真实服务端' }}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">技术栈与开源许可</span>
|
||
<span class="mx-badge mx-badge-neutral">MIT · OFL-1.1</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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in stack" :key="item.name">
|
||
<td class="name">{{ item.name }}</td>
|
||
<td class="muted">{{ item.usage }}</td>
|
||
<td>{{ item.license }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">字体清单</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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="font in fonts" :key="font.name">
|
||
<td class="name">{{ font.name }}</td>
|
||
<td class="muted">{{ font.usage }}</td>
|
||
<td>
|
||
{{ font.license }}
|
||
<span v-if="font.note" class="warn-note">{{ font.note }}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">键盘快捷键</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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in entryShortcuts" :key="`entry-${item.desc}`">
|
||
<td class="name">成绩录入</td>
|
||
<td class="keys">
|
||
<template v-for="(key, index) in item.keys" :key="key">
|
||
<span v-if="index > 0" class="plus">+</span><kbd>{{ key }}</kbd>
|
||
</template>
|
||
</td>
|
||
<td class="muted">{{ item.desc }}</td>
|
||
</tr>
|
||
<tr v-for="item in displayShortcuts" :key="`display-${item.desc}`">
|
||
<td class="name">大屏</td>
|
||
<td class="keys">
|
||
<template v-for="(key, index) in item.keys" :key="key">
|
||
<span v-if="index > 0" class="plus">+</span><kbd>{{ key }}</kbd>
|
||
</template>
|
||
</td>
|
||
<td class="muted">{{ item.desc }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="mx-card">
|
||
<div class="mx-card-head">
|
||
<span class="mx-card-title">部署信息</span>
|
||
</div>
|
||
<div class="mx-card-body">
|
||
<dl class="kv">
|
||
<div>
|
||
<dt>数据目录</dt>
|
||
<dd class="mono">— <span class="muted">(服务端未暴露该接口)</span></dd>
|
||
</div>
|
||
<div>
|
||
<dt>服务端口</dt>
|
||
<dd class="mono">{{ port }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>访问地址</dt>
|
||
<dd class="mono">{{ origin }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>赛事项目名</dt>
|
||
<dd>{{ eventName }}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</section>
|
||
|
||
<p class="foot-hint">
|
||
本系统为内部赛事使用;MiSans 字体授权仅限内部赛事,对外分发前请替换为 OFL-1.1 字体。
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.about-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
max-width: 960px;
|
||
}
|
||
.kv {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 14px 24px;
|
||
}
|
||
.kv > div {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
.kv dt {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.kv dd {
|
||
font-size: 14px;
|
||
word-break: break-all;
|
||
}
|
||
.en {
|
||
margin-left: 6px;
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
.no-pad {
|
||
padding: 0;
|
||
}
|
||
.name {
|
||
white-space: nowrap;
|
||
}
|
||
.muted {
|
||
color: var(--mx-t3);
|
||
}
|
||
.warn-note {
|
||
display: block;
|
||
margin-top: 2px;
|
||
font-size: 11px;
|
||
color: var(--mx-amber, #fbbf24);
|
||
}
|
||
.keys {
|
||
white-space: nowrap;
|
||
}
|
||
.plus {
|
||
margin: 0 3px;
|
||
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;
|
||
}
|
||
.foot-hint {
|
||
font-size: 12px;
|
||
color: var(--mx-t3);
|
||
}
|
||
</style>
|