fix(display): 隐藏调试标识 + 积分榜显示单位 logo
- TopBar 的「实例 N · 屏 N」只在该窗口确实处于调试态时渲染:非 windowed(kiosk)不显示, 窗口化下按 F11 全屏也不显示(同时监听 fullscreenchange,与 App.vue 口径一致) - 积分榜与团体奖牌榜的队名左侧渲染单位 logo:数据本来就下发了(dto/standings.go 的 LogoPath),只是页面没画。图标 1em 随行字号浮动、object-fit:contain,行高不会被撑开; 无 logo 不占位,加载失败 visibility:hidden 保留占位避免队名跳动,@load 会复原 - resolveAssetUrl(路径归一化)从 admin 目录收敛到 web/src/shared/asset.ts, 管理端与大屏共用一份;原 image-path 测试随之迁到 shared/tests 未做(需服务端 DTO 支持,本次未授权):分项成绩页与个人榜的单位列同样没有 logoPath 字段, 所以那两个页面仍无法显示单位 logo。 自检:vue-tsc + pnpm build 通过;前端 node --test 69/69。
This commit is contained in:
1 parent
407abec3b0
commit
6f47849e1e
7 files changed
+79
-5
No files matched your search
@@ -3,8 +3,8 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
import { resolveAssetUrl } from '@/shared'
|
||||||
import { describeError } from './systemApi'
|
import { describeError } from './systemApi'
|
||||||
import { resolveAssetUrl } from './imagePath'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片字段:文本框(可手填 / 粘贴路径)+ 上传按钮 + 缩略图预览。
|
* 图片字段:文本框(可手填 / 粘贴路径)+ 上传按钮 + 缩略图预览。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { formatClock } from '@/shared'
|
import { formatClock } from '@/shared'
|
||||||
import { useScoreboard } from '../useScoreboard'
|
import { useScoreboard } from '../useScoreboard'
|
||||||
|
import { isWindowed } from '../windowed'
|
||||||
import OfflineBadge from './OfflineBadge.vue'
|
import OfflineBadge from './OfflineBadge.vue'
|
||||||
|
|
||||||
const { settings, options } = useScoreboard()
|
const { settings, options } = useScoreboard()
|
||||||
@@ -19,7 +20,18 @@ const subtitle = computed(() => display.value?.subtitle?.trim() ?? '')
|
|||||||
const host = computed(() => settings.value?.event.host?.trim() || '主办方待配置')
|
const host = computed(() => settings.value?.event.host?.trim() || '主办方待配置')
|
||||||
const logo = computed(() => display.value?.logo?.trim() || '')
|
const logo = computed(() => display.value?.logo?.trim() || '')
|
||||||
const clock = computed(() => formatClock(now.value))
|
const clock = computed(() => formatClock(now.value))
|
||||||
|
// 单屏窗口化时用来分辨「这块屏是哪个实例」;现场展示(kiosk,或窗口化后按 F11 全屏)都不该出现。
|
||||||
|
// 监听在 setup 阶段就挂上,onUnmounted 里摘掉,不引入额外 import。
|
||||||
|
const fullscreen = ref(document.fullscreenElement !== null)
|
||||||
|
function onFullscreenChange(): void {
|
||||||
|
fullscreen.value = document.fullscreenElement !== null
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
|
||||||
|
|
||||||
|
// 实例/屏号都缺失时返回空串,模板的 v-if 顺带挡掉空元素。
|
||||||
const identity = computed(() => {
|
const identity = computed(() => {
|
||||||
|
if (!isWindowed() || fullscreen.value) return ''
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (options.value.instance !== null) parts.push(`实例 ${options.value.instance}`)
|
if (options.value.instance !== null) parts.push(`实例 ${options.value.instance}`)
|
||||||
if (options.value.screen !== null) parts.push(`屏 ${options.value.screen}`)
|
if (options.value.screen !== null) parts.push(`屏 ${options.value.screen}`)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { formatRankChange } from '@/shared'
|
import { formatRankChange, resolveAssetUrl } from '@/shared'
|
||||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||||
import { useScoreboard } from '../../useScoreboard'
|
import { useScoreboard } from '../../useScoreboard'
|
||||||
import AnimatedNumber from '../AnimatedNumber.vue'
|
import AnimatedNumber from '../AnimatedNumber.vue'
|
||||||
@@ -12,6 +12,12 @@ const rows = computed(() => standings.value)
|
|||||||
|
|
||||||
// 行高由 flex 均分,字号按行数算,单位再多也不会顶到轮播条下面。
|
// 行高由 flex 均分,字号按行数算,单位再多也不会顶到轮播条下面。
|
||||||
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
|
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
|
||||||
|
|
||||||
|
// 单位 logo 加载失败时不显示破图:失败藏起来、重新加载出图后再放出来,
|
||||||
|
// 两种情况下都保留占位,行内文字与行高都不会跳动。
|
||||||
|
function setLogoVisible(event: Event, visible: boolean): void {
|
||||||
|
;(event.target as HTMLImageElement).style.visibility = visible ? '' : 'hidden'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,7 +36,17 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX)
|
|||||||
:class="{ flash: rankDeltaFor(row.teamId) !== null }"
|
:class="{ flash: rankDeltaFor(row.teamId) !== null }"
|
||||||
>
|
>
|
||||||
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
||||||
<span class="name">{{ row.teamName }}</span>
|
<span class="name">
|
||||||
|
<img
|
||||||
|
v-if="row.logoPath"
|
||||||
|
class="team-logo"
|
||||||
|
:src="resolveAssetUrl(row.logoPath)"
|
||||||
|
alt=""
|
||||||
|
@error="setLogoVisible($event, false)"
|
||||||
|
@load="setLogoVisible($event, true)"
|
||||||
|
/>
|
||||||
|
<span class="team-name">{{ row.teamName }}</span>
|
||||||
|
</span>
|
||||||
<span class="delta">
|
<span class="delta">
|
||||||
<span v-if="rankDeltaFor(row.teamId) !== null" :class="rankDeltaFor(row.teamId)! > 0 ? 'up' : 'down'">
|
<span v-if="rankDeltaFor(row.teamId) !== null" :class="rankDeltaFor(row.teamId)! > 0 ? 'up' : 'down'">
|
||||||
{{ formatRankChange(rankDeltaFor(row.teamId)) }}
|
{{ formatRankChange(rankDeltaFor(row.teamId)) }}
|
||||||
@@ -90,6 +106,20 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX)
|
|||||||
color: #e0995e;
|
color: #e0995e;
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28em;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* logo 尺寸用 em 跟着行字号走:既不会撑开行高,也不会挤动名次/分数列。 */
|
||||||
|
.team-logo {
|
||||||
|
flex: none;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.team-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { resolveAssetUrl } from '@/shared'
|
||||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||||
import { useScoreboard } from '../../useScoreboard'
|
import { useScoreboard } from '../../useScoreboard'
|
||||||
import PageFrame from '../PageFrame.vue'
|
import PageFrame from '../PageFrame.vue'
|
||||||
@@ -10,6 +11,12 @@ const { standings } = useScoreboard()
|
|||||||
const rows = computed(() => standings.value)
|
const rows = computed(() => standings.value)
|
||||||
|
|
||||||
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
|
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
|
||||||
|
|
||||||
|
// 单位 logo 加载失败时不显示破图:失败藏起来、重新加载出图后再放出来,
|
||||||
|
// 两种情况下都保留占位,行内文字与行高都不会跳动。
|
||||||
|
function setLogoVisible(event: Event, visible: boolean): void {
|
||||||
|
;(event.target as HTMLImageElement).style.visibility = visible ? '' : 'hidden'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,7 +32,17 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX)
|
|||||||
</div>
|
</div>
|
||||||
<div v-for="row in rows" :key="row.teamId" class="row">
|
<div v-for="row in rows" :key="row.teamId" class="row">
|
||||||
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
||||||
<span class="name">{{ row.teamName }}</span>
|
<span class="name">
|
||||||
|
<img
|
||||||
|
v-if="row.logoPath"
|
||||||
|
class="team-logo"
|
||||||
|
:src="resolveAssetUrl(row.logoPath)"
|
||||||
|
alt=""
|
||||||
|
@error="setLogoVisible($event, false)"
|
||||||
|
@load="setLogoVisible($event, true)"
|
||||||
|
/>
|
||||||
|
<span class="team-name">{{ row.teamName }}</span>
|
||||||
|
</span>
|
||||||
<span class="medal gold">{{ row.gold }}</span>
|
<span class="medal gold">{{ row.gold }}</span>
|
||||||
<span class="medal silver">{{ row.silver }}</span>
|
<span class="medal silver">{{ row.silver }}</span>
|
||||||
<span class="medal bronze">{{ row.bronze }}</span>
|
<span class="medal bronze">{{ row.bronze }}</span>
|
||||||
@@ -85,6 +102,20 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX)
|
|||||||
color: #e0995e;
|
color: #e0995e;
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28em;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* logo 尺寸用 em 跟着行字号走:既不会撑开行高,也不会挤动名次/奖牌列。 */
|
||||||
|
.team-logo {
|
||||||
|
flex: none;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.team-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
File renamed without changes.
@@ -9,6 +9,7 @@ export * from './types'
|
|||||||
export { ApiClient, ApiError, createApiClient } from './api'
|
export { ApiClient, ApiError, createApiClient } from './api'
|
||||||
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
|
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
|
||||||
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
|
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
|
||||||
|
export { resolveAssetUrl } from './asset'
|
||||||
export { RealtimeClient } from './realtime'
|
export { RealtimeClient } from './realtime'
|
||||||
export type {
|
export type {
|
||||||
RealtimeInfo,
|
RealtimeInfo,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { resolveAssetUrl } from '../components/system/imagePath'
|
import { resolveAssetUrl } from '@/shared'
|
||||||
|
|
||||||
// 服务端上传接口返回的路径已带 /uploads/ 前缀,这里主要保证手填的历史值也能加载。
|
// 服务端上传接口返回的路径已带 /uploads/ 前缀,这里主要保证手填的历史值也能加载。
|
||||||
test('resolveAssetUrl 原样保留已带前缀的路径与绝对地址', () => {
|
test('resolveAssetUrl 原样保留已带前缀的路径与绝对地址', () => {
|
||||||
Reference in new issue
Block a user