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:
1 parent
24fff8dd08
commit
6483acf9ad
50 files changed
+2980
-395
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user