feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动

人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:05:06 +08:00
1 parent 24fff8dd08
commit 6483acf9ad
50 files changed
+2980 -395

No files matched your search

+13 -7
View File
@@ -3,14 +3,15 @@ import { computed, onMounted, ref } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { useEventStore } from '../stores/event'
import type { SystemAbout } from '@/shared/api'
const data = useEventStore()
const SYSTEM_NAME = '运动会积分榜系统'
const SYSTEM_EN = 'Scoreboard'
// /api/health 只回 version,/api/system/about 也不带构建时间;前端构建期未注入该常量,
// 因此这里如实显示「未注入」,不再用假的 'dev' 冒充构建时间。
const BUILD_TIME = '未注入'
// 构建时间由服务端构建期注入,随 /api/system/about 一起取回;没注入(空串)或请求失败时
// 如实显示「未注入」,不用假的 'dev' 冒充。
const BUILD_TIME_UNKNOWN = '未注入'
const version = ref<string | null>(null)
const versionChecked = ref(false)
@@ -73,8 +74,12 @@ const port = computed(() => {
const origin = computed(() => window.location.origin)
// 内部署名由构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串
// 内部署名与构建时间都由服务端构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串
const internalSignature = ref('')
const buildTime = ref('')
// 空串(未注入)/ 请求失败一律显示「未注入」,不留空白
const buildTimeText = computed(() => buildTime.value.trim() || BUILD_TIME_UNKNOWN)
onMounted(async () => {
// 直接从 /api/health 取版本;不走 adminApi.health 的示例数据兜底,避免把 mock 当成真实版本
@@ -90,11 +95,12 @@ onMounted(async () => {
try {
const res = await fetch('/api/system/about', { credentials: 'include' })
if (res.ok) {
const body = (await res.json()) as { ok: boolean; data?: { internalSignature?: string } }
const body = (await res.json()) as { ok: boolean; data?: SystemAbout }
internalSignature.value = body.data?.internalSignature ?? ''
buildTime.value = body.data?.buildTime ?? ''
}
} catch {
// 未登录 / 接口不可达都按「没有署名」处理,不打扰用户
// 未登录 / 接口不可达都按「没有署名、没有构建时间」处理,不打扰用户
}
if (!data.loaded) void data.load()
@@ -132,7 +138,7 @@ onMounted(async () => {
</div>
<div>
<dt>构建时间</dt>
<dd class="mono">{{ BUILD_TIME }}</dd>
<dd class="mono">{{ buildTimeText }}</dd>
</div>
<div>
<dt>运行模式</dt>
+244 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -37,6 +37,115 @@ const announceText = ref('')
const announceSeconds = ref(15)
const pending = ref<string | null>(null)
/**
* 显示器菜单:只有「打开服务端管理面板」那个 Electron 窗口里的 shell-preload 会注入
* window.desktopShell.displays,浏览器直接打开面板时没有这一组能力。
*/
interface DesktopDisplayInfo {
id: number
label: string
primary: boolean
width: number
height: number
scaleFactor: number
open: boolean
page: string | null
mode: 'kiosk' | 'windowed' | null
}
interface DesktopDisplayOpenResult {
ok: boolean
created: boolean
mode: 'kiosk' | 'windowed' | null
/** 请求全屏但被 Console 按规则降级时的原因。 */
message?: string
}
interface DesktopDisplaysApi {
list: () => Promise<{ single: boolean; displays: DesktopDisplayInfo[] }>
open: (displayId: number, options: { page: string; fullscreen: boolean }) => Promise<DesktopDisplayOpenResult>
close: (displayId: number) => Promise<{ ok: boolean; closed: boolean; message?: string }>
relayout: () => Promise<unknown>
}
// window.desktopShell 的全局声明里没有 displays 这一组,这里只在组件内部放宽,不动共享类型
const displaysApi =
typeof window === 'undefined'
? null
: ((window as unknown as { desktopShell?: { displays?: DesktopDisplaysApi } }).desktopShell?.displays ?? null)
const hasDisplayMenu = displaysApi !== null
/** 给新开的窗口锁定的页面,默认总积分榜。 */
const windowPage = ref<DisplayPage>('standings')
const displayList = ref<DesktopDisplayInfo[]>([])
const singleDisplay = ref(true)
const displayBusy = ref(false)
/** 全屏只放行「多屏 + 非主屏」,与 Console 主进程的判定保持一致。 */
function canFullscreen(item: DesktopDisplayInfo): boolean {
return !singleDisplay.value && !item.primary
}
async function loadDisplays(): Promise<void> {
if (!displaysApi) return
try {
const payload = await displaysApi.list()
displayList.value = payload.displays
// 单屏与否以 Console 的枚举结果为准,不看浏览器自己的 window.screen
singleDisplay.value = payload.single === true
} catch (caught) {
toast.error('读取显示器列表失败', caught instanceof Error ? caught.message : null)
}
}
async function runDisplayAction(label: string, action: () => Promise<void>): Promise<void> {
displayBusy.value = true
try {
await action()
} catch (caught) {
toast.error(`${label}失败`, caught instanceof Error ? caught.message : null)
} finally {
displayBusy.value = false
await loadDisplays()
}
}
async function openDisplayWindow(item: DesktopDisplayInfo): Promise<void> {
if (!displaysApi) return
await runDisplayAction('打开大屏窗口', async () => {
const result = await displaysApi.open(item.id, { page: windowPage.value, fullscreen: canFullscreen(item) })
if (!result.ok) {
toast.error('打开大屏窗口失败', result.message ?? null)
return
}
// 一律以 Console 返回的实际模式为准:请求全屏也可能被降级成窗口化
const mode = result.mode === 'kiosk' ? '全屏窗口' : '窗口化窗口'
toast.success(`已${result.created ? '创建' : '聚焦'}「${item.label}」的${mode}`, result.message ?? null)
})
}
async function closeDisplayWindow(item: DesktopDisplayInfo): Promise<void> {
if (!displaysApi) return
await runDisplayAction('关闭大屏窗口', async () => {
const result = await displaysApi.close(item.id)
if (!result.ok) {
toast.error('关闭大屏窗口失败', result.message ?? null)
return
}
toast.success(result.closed ? `已关闭「${item.label}」上的大屏窗口` : `「${item.label}」上没有大屏窗口`)
})
}
async function relayoutDisplays(): Promise<void> {
if (!displaysApi) return
await runDisplayAction('重新排布大屏窗口', async () => {
await displaysApi.relayout()
toast.success('已请求按设置重建全部大屏窗口')
})
}
onMounted(loadDisplays)
/**
* 在线看板(占位)。
*
@@ -203,6 +312,89 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">显示器</span>
<span v-if="hasDisplayMenu" class="mx-badge mx-badge-neutral">{{ displayList.length }} 块屏</span>
</div>
<div class="mx-card-body">
<p v-if="!hasDisplayMenu" class="display-note">
此功能需要在服务管理窗口里点「打开服务端管理面板」打开的面板中使用。
</p>
<template v-else>
<label class="display-page-pick">
<span class="display-pick-label">窗口页面</span>
<span class="mx-input-wrap display-pick-select">
<select v-model="windowPage" class="mx-input">
<option v-for="page in PAGES" :key="page.key" :value="page.key">{{ page.label }}</option>
</select>
</span>
</label>
<p v-if="displayList.length === 0" class="display-note">没有读到显示器,请确认 Console(服务管理程序)正在本机运行。</p>
<div v-for="item in displayList" :key="item.id" class="display-row">
<div class="display-meta">
<div class="display-name">
{{ item.label }}
<span v-if="item.primary" class="mx-badge mx-badge-neutral">主显示器</span>
</div>
<div class="display-sub">
{{ item.width }} × {{ item.height }} · {{ item.scaleFactor }}x ·
{{ item.open ? `已开窗口(${item.mode === 'kiosk' ? '全屏' : '窗口化'})` : '未开窗口' }}
</div>
<div v-if="!item.open && !canFullscreen(item)" class="display-sub">单显示器 / 主显示器只能窗口化</div>
</div>
<div class="display-actions">
<button
v-if="item.open"
class="mx-btn mx-btn-secondary"
type="button"
:disabled="displayBusy"
@click="openDisplayWindow(item)"
>
<MxIcon name="push" :size="15" /> 聚焦窗口
</button>
<button
v-else-if="canFullscreen(item)"
class="mx-btn mx-btn-primary"
type="button"
:disabled="displayBusy"
@click="openDisplayWindow(item)"
>
<MxIcon name="display" :size="15" /> 创建全屏窗口
</button>
<button
v-else
class="mx-btn mx-btn-secondary"
type="button"
:disabled="displayBusy"
@click="openDisplayWindow(item)"
>
<MxIcon name="display" :size="15" /> 创建窗口(窗口化)
</button>
<button
v-if="item.open"
class="mx-btn mx-btn-secondary"
type="button"
:disabled="displayBusy"
@click="closeDisplayWindow(item)"
>
<MxIcon name="close" :size="15" /> 关闭窗口
</button>
</div>
</div>
<button class="mx-btn mx-btn-secondary full" type="button" :disabled="displayBusy" @click="relayoutDisplays">
<MxIcon name="refresh" :size="15" /> 重新排布(按设置重建全部窗口)
</button>
<p class="display-note">
全屏窗口只在「多块显示器 + 非主显示器」上开放:单显示器或主显示器上开全屏会把桌面整个盖住又没法退出,所以只能开窗口化窗口。副屏全屏窗口被误关后,点该屏的「创建全屏窗口」即可恢复;「重新排布」按 Console 配置的选屏策略重建全部窗口(不在策略内的窗口会被关掉)。
</p>
</template>
</div>
</section>
</div>
<section class="mx-card">
@@ -363,6 +555,57 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
.board-hint .mock {
color: var(--mx-amber);
}
.display-page-pick {
display: flex;
align-items: center;
gap: 10px;
}
.display-pick-label {
font-size: 12px;
color: var(--mx-t3);
white-space: nowrap;
}
.display-pick-select {
flex: 1;
height: 34px;
padding: 0 12px;
}
.display-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.display-meta {
min-width: 0;
flex: 1;
}
.display-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.display-sub {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.display-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.display-note {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
@media (max-width: 1023px) {
.display-grid {
grid-template-columns: 1fr;