Commit bca96c6a by James Yuan

feat: 大屏多语言切换,并减少场景重复飞视点

顶部提供简/繁/EN/PT 快捷切换;打点图标网格仅显示图标三列;同书签短时去重避免刷新连播 Changing。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 83d33602
...@@ -477,6 +477,67 @@ ...@@ -477,6 +477,67 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@intlify/core-base": {
"version": "11.4.16",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.16.tgz",
"integrity": "sha512-xB8nMdMRLNx7b0zPvrHy0+N9uq9Vfi1aUxHqWgn2e2/TbjCKy8HfoFpnUXXxVP8I0p1ccd6LFSC3XsFjZ3vweA==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.16",
"@intlify/message-compiler": "11.4.16",
"@intlify/shared": "11.4.16"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.16",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.16.tgz",
"integrity": "sha512-Bl7arICsKgwcSEQuZJun3swenN4jwSpg35lCpijFznYbW9GvkfH4wLxcI5+gz7d/EHqu66UT9CZDIdnQdgMAGg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.16",
"@intlify/shared": "11.4.16"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.16",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.16.tgz",
"integrity": "sha512-p4lDWmBAsi3ja+PuEfPRavYyvqkYn/Upxz7UpnyI9LgIIy8XawcGIDzN+k99Z+99etO3HmDXGeTxnJIJGgSZ+g==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.16",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.16",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.16.tgz",
"integrity": "sha512-xehmkbEgbbNh/V+d8r3hFup0ViaN29YTJSAfRr28C54rxjyZEO0RNQKqW7VVajj16gLdglotNjiq+sIVNtHGzw==",
"license": "MIT",
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
...@@ -4134,6 +4195,33 @@ ...@@ -4134,6 +4195,33 @@
"vue": "^3.3.0" "vue": "^3.3.0"
} }
}, },
"node_modules/vue-i18n": {
"version": "11.4.16",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.16.tgz",
"integrity": "sha512-Kv9tp6iTmaAeOwop7hkAo28N1IBsbSVpnxyE26b6gqc4Q2E9Ke3ZHHA3oqqwS6/cYLT1henZU4etTaLQxrK98Q==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.16",
"@intlify/devtools-types": "11.4.16",
"@intlify/shared": "11.4.16",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-i18n/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.6.4", "version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
...@@ -4320,6 +4408,7 @@ ...@@ -4320,6 +4408,7 @@
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.42", "vue": "^3.5.42",
"vue-echarts": "^8.3.2", "vue-echarts": "^8.3.2",
"vue-i18n": "^11.4.16",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
......
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
"pinia": "^3.0.1", "pinia": "^3.0.1",
"vue": "^3.5.42", "vue": "^3.5.42",
"vue-echarts": "^8.3.2", "vue-echarts": "^8.3.2",
"vue-i18n": "^11.4.16",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
...@@ -161,7 +164,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -161,7 +164,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<button <button
class="near-close" class="near-close"
type="button" type="button"
aria-label="关闭" :aria-label="t('screen.close')"
@pointerdown.stop="requestClose" @pointerdown.stop="requestClose"
@click.stop.prevent="requestClose" @click.stop.prevent="requestClose"
> >
...@@ -194,9 +197,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -194,9 +197,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</ul> </ul>
<div v-else class="near-switch"> <div v-else class="near-switch">
<p class="near-switch-status"> <p class="near-switch-status">
当前: {{ t('screen.current') }}:
<b :class="{ on: switchOn === true, off: switchOn === false }"> <b :class="{ on: switchOn === true, off: switchOn === false }">
{{ switchOn == null ? '未知' : switchOn ? '开' : '关' }} {{
switchOn == null
? t('screen.unknown')
: switchOn
? t('screen.switchOn')
: t('screen.switchOff')
}}
</b> </b>
</p> </p>
<div class="near-switch-actions"> <div class="near-switch-actions">
...@@ -206,7 +215,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -206,7 +215,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:disabled="switching" :disabled="switching"
@click="emit('control', true)" @click="emit('control', true)"
> >
开 {{ t('screen.switchOn') }}
</button> </button>
<button <button
type="button" type="button"
...@@ -214,7 +223,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) ...@@ -214,7 +223,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:disabled="switching" :disabled="switching"
@click="emit('control', false)" @click="emit('control', false)"
> >
关 {{ t('screen.switchOff') }}
</button> </button>
</div> </div>
<p v-if="switchError" class="near-switch-err">{{ switchError }}</p> <p v-if="switchError" class="near-switch-err">{{ switchError }}</p>
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode' import { useScreenModeStore } from '@/stores/screenMode'
import { SCREEN_AREAS } from '@/data/rooms' import { SCREEN_AREAS } from '@/data/rooms'
...@@ -38,6 +39,7 @@ const props = withDefaults( ...@@ -38,6 +39,7 @@ const props = withDefaults(
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const screenMode = useScreenModeStore() const screenMode = useScreenModeStore()
const auth = useAuthStore() const auth = useAuthStore()
const { t } = useI18n()
const { mode: activeMode } = storeToRefs(screenMode) const { mode: activeMode } = storeToRefs(screenMode)
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine' type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
...@@ -344,7 +346,7 @@ function findLightSensor(marker: SceneMarkerHit, markerId: string | null): Senso ...@@ -344,7 +346,7 @@ function findLightSensor(marker: SceneMarkerHit, markerId: string | null): Senso
async function onLightControl(on: boolean) { async function onLightControl(on: boolean) {
if (!nearSensorId.value) return if (!nearSensorId.value) return
if (!auth.token) { if (!auth.token) {
nearSwitchError.value = '请先登录管理员账号后再控制' nearSwitchError.value = t('screen.loginToControl')
return return
} }
nearSwitching.value = true nearSwitching.value = true
...@@ -369,7 +371,7 @@ async function onLightControl(on: boolean) { ...@@ -369,7 +371,7 @@ async function onLightControl(on: boolean) {
// false:只对变化的灯 setLightVisible,避免整表 clear 造成闪一下看不见 // false:只对变化的灯 setLightVisible,避免整表 clear 造成闪一下看不见
window.setTimeout(() => syncLightEffects(), 0) window.setTimeout(() => syncLightEffects(), 0)
} catch (e) { } catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : '控制失败' nearSwitchError.value = e instanceof Error ? e.message : t('screen.controlFailed')
} finally { } finally {
nearSwitching.value = false nearSwitching.value = false
} }
...@@ -534,7 +536,7 @@ function onMarkerClick(payload: { ...@@ -534,7 +536,7 @@ function onMarkerClick(payload: {
nearLines.value = [] nearLines.value = []
nearSensorId.value = light?.id || light?.code || null nearSensorId.value = light?.id || light?.code || null
nearSwitchOn.value = light?.on ?? null nearSwitchOn.value = light?.on ?? null
nearSwitchError.value = light ? '' : '未找到对应照明设施,请检查设施管理绑定' nearSwitchError.value = light ? '' : t('screen.lightNotBound')
openNearDialog(light?.name || marker.name || '照明控制', markerId || marker.id, raw) openNearDialog(light?.name || marker.name || '照明控制', markerId || marker.id, raw)
return return
} }
...@@ -760,7 +762,9 @@ onBeforeUnmount(() => { ...@@ -760,7 +762,9 @@ onBeforeUnmount(() => {
<div v-if="props.layout !== 'fill'" class="vp-topbar"> <div v-if="props.layout !== 'fill'" class="vp-topbar">
<AppIcon name="pin" /> <AppIcon name="pin" />
<span class="area">{{ areaLabel || `当前区域:${selected?.name || '—'}` }}</span> <span class="area">{{
areaLabel || `${t('screen.currentArea')}:${selected ? t(`areas.${selected.id}`) : '—'}`
}}</span>
<span class="sep" /> <span class="sep" />
<span class="view">{{ modeLabel }}</span> <span class="view">{{ modeLabel }}</span>
</div> </div>
...@@ -768,7 +772,7 @@ onBeforeUnmount(() => { ...@@ -768,7 +772,7 @@ onBeforeUnmount(() => {
<!-- 全屏:右侧设备分类(只切图标)+ 底栏区域飞视 --> <!-- 全屏:右侧设备分类(只切图标)+ 底栏区域飞视 -->
<Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer> <Teleport v-if="props.layout === 'fill' && floatTargetReady" to="#screen-float-overlays" defer>
<!-- 右侧:设备分类。点一类只显示该类图标;全景总览在底栏测序区前面 --> <!-- 右侧:设备分类。点一类只显示该类图标;全景总览在底栏测序区前面 -->
<div class="vp-area-side" aria-label="设备分类"> <div class="vp-area-side" :aria-label="t('screen.deviceCategory')">
<button <button
v-for="item in DEVICE_MENU" v-for="item in DEVICE_MENU"
:key="item.id" :key="item.id"
...@@ -777,12 +781,12 @@ onBeforeUnmount(() => { ...@@ -777,12 +781,12 @@ onBeforeUnmount(() => {
:class="{ active: item.id === activeMode }" :class="{ active: item.id === activeMode }"
@click="onSelectMode(item.id)" @click="onSelectMode(item.id)"
> >
<span class="name">{{ item.label }}</span> <span class="name">{{ t(`modes.${item.id}`) }}</span>
</button> </button>
</div> </div>
<!-- 底栏:全景总览在测序区前面,其后为各区域飞视 --> <!-- 底栏:全景总览在测序区前面,其后为各区域飞视 -->
<div class="vp-mode-bar" aria-label="区域飞视"> <div class="vp-mode-bar" :aria-label="t('screen.areaFly')">
<button <button
type="button" type="button"
class="vp-mode-item" class="vp-mode-item"
...@@ -790,7 +794,7 @@ onBeforeUnmount(() => { ...@@ -790,7 +794,7 @@ onBeforeUnmount(() => {
@click="onSelectOverview" @click="onSelectOverview"
> >
<AppIcon name="pin" class-name="room-ico" /> <AppIcon name="pin" class-name="room-ico" />
<span>全景总览</span> <span>{{ t('screen.overview') }}</span>
</button> </button>
<button <button
v-for="r in areaList" v-for="r in areaList"
...@@ -801,7 +805,7 @@ onBeforeUnmount(() => { ...@@ -801,7 +805,7 @@ onBeforeUnmount(() => {
@click="onSelectArea(r.id)" @click="onSelectArea(r.id)"
> >
<AppIcon name="pin" class-name="room-ico" /> <AppIcon name="pin" class-name="room-ico" />
<span>{{ r.name }}</span> <span>{{ t(`areas.${r.id}`) }}</span>
</button> </button>
</div> </div>
</Teleport> </Teleport>
...@@ -809,8 +813,8 @@ onBeforeUnmount(() => { ...@@ -809,8 +813,8 @@ onBeforeUnmount(() => {
<div class="vp-room-head"> <div class="vp-room-head">
<AppIcon name="pin" /> <AppIcon name="pin" />
<div> <div>
<div class="vp-room-label">区域导航</div> <div class="vp-room-label">{{ t('screen.areaNav') }}</div>
<div class="vp-room-sub">点击飞视点</div> <div class="vp-room-sub">{{ t('screen.clickFly') }}</div>
</div> </div>
</div> </div>
<div class="vp-room-list"> <div class="vp-room-list">
...@@ -821,7 +825,7 @@ onBeforeUnmount(() => { ...@@ -821,7 +825,7 @@ onBeforeUnmount(() => {
@click="onSelectOverview" @click="onSelectOverview"
> >
<AppIcon name="pin" class-name="room-ico" /> <AppIcon name="pin" class-name="room-ico" />
<span class="name">全景总览</span> <span class="name">{{ t('screen.overview') }}</span>
</button> </button>
<button <button
v-for="r in areaList" v-for="r in areaList"
...@@ -832,7 +836,7 @@ onBeforeUnmount(() => { ...@@ -832,7 +836,7 @@ onBeforeUnmount(() => {
@click="onSelectArea(r.id)" @click="onSelectArea(r.id)"
> >
<AppIcon name="pin" class-name="room-ico" /> <AppIcon name="pin" class-name="room-ico" />
<span class="name">{{ r.name }}</span> <span class="name">{{ t(`areas.${r.id}`) }}</span>
</button> </button>
</div> </div>
</div> </div>
...@@ -864,12 +868,8 @@ onBeforeUnmount(() => { ...@@ -864,12 +868,8 @@ onBeforeUnmount(() => {
<div v-if="props.layout !== 'fill'" class="vp-tip"> <div v-if="props.layout !== 'fill'" class="vp-tip">
<AppIcon name="hand" class-name="hand" /> <AppIcon name="hand" class-name="hand" />
<span v-if="useCloudFrame"> <span v-if="useCloudFrame">{{ t('screen.tipCloud') }}</span>
RTEngineHD 云渲染 · 在画面内操作漫游 · 底部点选区域飞视点 <span v-else>{{ t('screen.tipLocal') }}</span>
</span>
<span v-else>
<b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 底部点选区域
</span>
</div> </div>
<MarkerNearDialog <MarkerNearDialog
......
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import i18n, { LOCALE_OPTIONS, persistLocale, type AppLocale } from '@/i18n'
const { t, locale } = useI18n({ useScope: 'global' })
function setLocale(id: AppLocale) {
locale.value = id
i18n.global.locale.value = id
persistLocale(id)
}
</script>
<template>
<div class="lang-switch" role="group" :aria-label="t('screen.language')">
<button
v-for="opt in LOCALE_OPTIONS"
:key="opt.id"
type="button"
class="lang-btn"
:class="{ active: locale === opt.id }"
@click="setLocale(opt.id)"
>
{{ opt.short }}
</button>
</div>
</template>
<style scoped>
.lang-switch {
display: inline-flex;
align-items: center;
flex-shrink: 0;
height: 28px;
padding: 2px;
gap: 2px;
border-radius: 3px;
border: 1px solid rgba(61, 190, 255, 0.45);
background: rgba(6, 12, 20, 0.55);
pointer-events: auto;
}
.lang-btn {
min-width: 28px;
height: 22px;
padding: 0 6px;
border: 0;
border-radius: 2px;
background: transparent;
color: #cfe6ff;
cursor: pointer;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1;
}
.lang-btn:hover {
color: #fff;
background: rgba(61, 190, 255, 0.16);
}
.lang-btn.active {
color: #041018;
background: #3dbeff;
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import headerBar from '@/assets/screen/header-bar.png' import headerBar from '@/assets/screen/header-bar.png'
defineProps<{ defineProps<{
title?: string title?: string
}>() }>()
const { t } = useI18n()
</script> </script>
<template> <template>
<header class="screen-header-hy"> <header class="screen-header-hy">
<img class="banner" :src="headerBar" alt="" /> <img class="banner" :src="headerBar" alt="" />
<h1>{{ title || '分子实验室三维可视化管理平台' }}</h1> <h1>{{ title || t('screen.defaultTitle') }}</h1>
<div class="slot-left"> <div class="slot-left">
<slot name="left" /> <slot name="left" />
</div> </div>
......
...@@ -273,10 +273,10 @@ function iconSrc(file: string) { ...@@ -273,10 +273,10 @@ function iconSrc(file: string) {
class="icon-item" class="icon-item"
:class="{ active: selectedIcon === f }" :class="{ active: selectedIcon === f }"
:title="f" :title="f"
:aria-label="f"
@click="selectedIcon = f" @click="selectedIcon = f"
> >
<img :src="iconSrc(f)" :alt="f" loading="lazy" /> <img :src="iconSrc(f)" :alt="f" loading="lazy" />
<span>{{ f.replace(/\.png$/i, '') }}</span>
</button> </button>
</div> </div>
<div class="icon-current"> <div class="icon-current">
...@@ -404,34 +404,26 @@ function iconSrc(file: string) { ...@@ -404,34 +404,26 @@ function iconSrc(file: string) {
overflow: auto; overflow: auto;
padding: 8px; padding: 8px;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(3, 1fr);
gap: 6px; gap: 8px;
align-content: start; align-content: start;
} }
.icon-item { .icon-item {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
padding: 8px 4px; min-height: 88px;
padding: 10px 6px;
border-radius: 6px; border-radius: 6px;
border: 1px solid rgba(0, 247, 255, 0.16); border: 1px solid rgba(0, 247, 255, 0.16);
background: rgba(8, 28, 56, 0.45); background: rgba(8, 28, 56, 0.45);
color: #9ec9e0;
cursor: pointer; cursor: pointer;
font-size: 10px;
} }
.icon-item img { .icon-item img {
width: 36px; width: 52px;
height: 36px; height: 52px;
object-fit: contain; object-fit: contain;
} }
.icon-item span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-item:hover { .icon-item:hover {
border-color: rgba(0, 247, 255, 0.45); border-color: rgba(0, 247, 255, 0.45);
} }
......
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const weekMap = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
function p2(n: number) { function p2(n: number) {
return n < 10 ? `0${n}` : `${n}` return n < 10 ? `0${n}` : `${n}`
} }
export function useClock() { export function useClock() {
const { tm, locale } = useI18n()
const time = ref('--:--:--') const time = ref('--:--:--')
const date = ref('') const date = ref('')
let timer: number | undefined let timer: number | undefined
...@@ -14,9 +14,13 @@ export function useClock() { ...@@ -14,9 +14,13 @@ export function useClock() {
function tick() { function tick() {
const d = new Date() const d = new Date()
time.value = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}` time.value = `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
date.value = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${weekMap[d.getDay()]}` const weeks = tm('weekdays') as string[]
const week = Array.isArray(weeks) ? weeks[d.getDay()] || '' : ''
date.value = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${week}`
} }
watch(locale, () => tick())
onMounted(() => { onMounted(() => {
tick() tick()
timer = window.setInterval(tick, 1000) timer = window.setInterval(tick, 1000)
......
...@@ -374,6 +374,20 @@ export function useRtengineBridge(opts: { ...@@ -374,6 +374,20 @@ export function useRtengineBridge(opts: {
const pendingFlies: string[] = [] const pendingFlies: string[] = []
let bookmarksEnsured = false let bookmarksEnsured = false
/** 同一书签短时间内只飞一次。引擎过早调用会自己排队补飞,再重试就会连播 Changing */
const recentFlies = new Map<string, number>()
const FLY_DEDUPE_MS = 2500
function rememberFly(id: string) {
recentFlies.set(id, Date.now())
const i = pendingFlies.indexOf(id)
if (i >= 0) pendingFlies.splice(i, 1)
}
function recentlyFlew(id: string) {
const at = recentFlies.get(id)
return at != null && Date.now() - at < FLY_DEDUPE_MS
}
/** 从 /designer 拉取书签并 register,避免 iframe 内尚未 hydrate 时 fly 被排队吞掉 */ /** 从 /designer 拉取书签并 register,避免 iframe 内尚未 hydrate 时 fly 被排队吞掉 */
async function ensureBookmarks() { async function ensureBookmarks() {
...@@ -408,34 +422,41 @@ export function useRtengineBridge(opts: { ...@@ -408,34 +422,41 @@ export function useRtengineBridge(opts: {
} }
} }
/** 同域直调 $gisObj.flyToBookmark("测序区")(与引擎 console 一致) */ /**
* 同域直调 $gisObj.flyToBookmark。
* 过早调用时引擎会排队并在书签注册后自动补飞一次;这里不再 600ms/2s 重试,
* 否则每次刷新会连播多次 Changing。
*/
function flyToBookmark(name: string, options?: Record<string, unknown>) { function flyToBookmark(name: string, options?: Record<string, unknown>) {
const id = String(name || '').trim() const id = String(name || '').trim()
if (!id) return if (!id || recentlyFlew(id)) return
const tryDirect = (): boolean => { const gis = gisObj()
const gis = gisObj() if (gis && typeof gis.flyToBookmark === 'function') {
if (!gis || typeof gis.flyToBookmark !== 'function') return false
try { try {
const ret = options ? gis.flyToBookmark(id, options) : gis.flyToBookmark(id) const ret = options ? gis.flyToBookmark(id, options) : gis.flyToBookmark(id)
if (ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued) { const queued = !!(ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued)
if (!pendingFlies.includes(id)) pendingFlies.push(id) // queued 也算已交给引擎,注册完成后它会自己补飞,不要再打第二次
} rememberFly(id)
return true if (queued) return
return
} catch { } catch {
return false /* 直调失败再排队一次 */
} }
} }
if (tryDirect()) return
if (!pendingFlies.includes(id)) pendingFlies.push(id) if (!pendingFlies.includes(id)) pendingFlies.push(id)
send('flyToBookmark', id)
void ensureBookmarks().then(() => { void ensureBookmarks().then(() => {
tryDirect() if (recentlyFlew(id)) return
const again = gisObj()
if (!again || typeof again.flyToBookmark !== 'function') return
try {
again.flyToBookmark(id, options)
rememberFly(id)
} catch {
/* 留给 markReady 的一次 flush */
}
}) })
window.setTimeout(() => tryDirect(), 600)
window.setTimeout(() => tryDirect(), 2000)
} }
function flushPendingFlies() { function flushPendingFlies() {
...@@ -813,21 +834,15 @@ export function useRtengineBridge(opts: { ...@@ -813,21 +834,15 @@ export function useRtengineBridge(opts: {
} }
let readyNotified = false let readyNotified = false
let flyFlushed = false
function markReady() { function markReady() {
if (readyNotified) { if (readyNotified) return
// 已就绪:只冲未飞完的视点,不再反复 ensureBookmarks
if (pendingFlies.length) flushPendingFlies()
return
}
readyNotified = true readyNotified = true
ready.value = true ready.value = true
opts.onReady?.() opts.onReady?.()
if (flyFlushed) return
flyFlushed = true
void ensureBookmarks().then(() => flushPendingFlies()) void ensureBookmarks().then(() => flushPendingFlies())
// 书签 / $gisObj 可能稍晚就绪,再冲几次排队飞视
window.setTimeout(() => {
void ensureBookmarks().then(() => flushPendingFlies())
}, 800)
window.setTimeout(() => flushPendingFlies(), 2500)
} }
function onMessage(ev: MessageEvent) { function onMessage(ev: MessageEvent) {
......
import { watch, type Ref } from 'vue' import { watch, type Ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode' import { useScreenModeStore } from '@/stores/screenMode'
import { import {
...@@ -18,6 +19,7 @@ import { ...@@ -18,6 +19,7 @@ import {
type RtengineMarker, type RtengineMarker,
} from '@/composables/useRtengineBridge' } from '@/composables/useRtengineBridge'
import type { RealtimeSnapshot, SensorSnapshot } from '@/types' import type { RealtimeSnapshot, SensorSnapshot } from '@/types'
import { tt } from '@/i18n/t'
const MARKER_LEADER = { const MARKER_LEADER = {
leader: true as const, leader: true as const,
...@@ -64,27 +66,29 @@ function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string { ...@@ -64,27 +66,29 @@ function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string {
} }
if (bind.kind === 'vav') { if (bind.kind === 'vav') {
const v = snap.vav.find((x) => x.id === bind.deviceId) const v = snap.vav.find((x) => x.id === bind.deviceId)
const run = v?.running ? '运行' : '停止' const run = v?.running ? tt('screen.running') : tt('screen.stopped')
return `状态:${run}\n面风速:${fmtNum(v?.faceVelocity)}m/s\n风量:${fmtNum(v?.airflow, 0)}` return `${tt('screen.status')}:${run}\n${tt('screen.faceVelocity')}:${fmtNum(v?.faceVelocity)}m/s\n${tt('screen.airflow')}:${fmtNum(v?.airflow, 0)}`
} }
if (bind.kind === 'neLight') { if (bind.kind === 'neLight') {
const lit = (snap.neLights || []).find((x) => x.id === bind.lightId) const lit = (snap.neLights || []).find((x) => x.id === bind.lightId)
return `状态:${lit?.on ? '开' : '关'}` const st = lit?.on ? tt('screen.switchOn') : tt('screen.switchOff')
return `${tt('screen.status')}:${st}`
} }
return def.label return def.label
} }
function sensorText(s: SensorSnapshot): string { function sensorText(s: SensorSnapshot): string {
if (s.category === 'lightN' || s.category === 'lightE') { if (s.category === 'lightN' || s.category === 'lightE') {
return s.on == null ? '—' : s.on ? '开' : '关' return s.on == null ? '—' : s.on ? tt('screen.switchOn') : tt('screen.switchOff')
} }
if (s.category === 'fridge') { if (s.category === 'fridge') {
const v = s.values || { temp: null, humidity: null, pressure: null } const v = s.values || { temp: null, humidity: null, pressure: null }
return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%` return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%`
} }
if (s.category === 'vav') { if (s.category === 'vav') {
const run = s.on == null ? '—' : s.on ? '运行' : '停止' const run =
return `状态:${run}\n面风速:${fmtNum(s.vav?.faceVelocity)}m/s\n风量:${fmtNum(s.vav?.airflow, 0)}` s.on == null ? '—' : s.on ? tt('screen.running') : tt('screen.stopped')
return `${tt('screen.status')}:${run}\n${tt('screen.faceVelocity')}:${fmtNum(s.vav?.faceVelocity)}m/s\n${tt('screen.airflow')}:${fmtNum(s.vav?.airflow, 0)}`
} }
if (s.category === 'camera' || s.category === 'access') { if (s.category === 'camera' || s.category === 'access') {
return s.name return s.name
...@@ -190,6 +194,7 @@ function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarke ...@@ -190,6 +194,7 @@ function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarke
export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cloudUrl: string) { export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cloudUrl: string) {
const modeStore = useScreenModeStore() const modeStore = useScreenModeStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const { locale } = useI18n()
const { mode } = storeToRefs(modeStore) const { mode } = storeToRefs(modeStore)
let activeIds: string[] = [] let activeIds: string[] = []
...@@ -409,6 +414,12 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl ...@@ -409,6 +414,12 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
() => scheduleSync(), () => scheduleSync(),
) )
// 切换语言后刷新场景标注文案
watch(locale, () => {
lastTextSig = ''
scheduleSync()
})
return { return {
clearModeMarkers, clearModeMarkers,
syncModePois, syncModePois,
......
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import zhTW from './locales/zh-TW'
import en from './locales/en'
import pt from './locales/pt'
export const LOCALE_OPTIONS = [
{ id: 'zh-CN' as const, short: '简' },
{ id: 'zh-TW' as const, short: '繁' },
{ id: 'en' as const, short: 'EN' },
{ id: 'pt' as const, short: 'PT' },
]
export type AppLocale = (typeof LOCALE_OPTIONS)[number]['id']
const STORAGE_KEY = 'jh-lab-locale'
export function detectLocale(): AppLocale {
try {
const saved = localStorage.getItem(STORAGE_KEY) as AppLocale | null
if (saved && LOCALE_OPTIONS.some((o) => o.id === saved)) return saved
} catch {
/* ignore */
}
const nav = (typeof navigator !== 'undefined' ? navigator.language : 'zh-CN').toLowerCase()
if (nav.startsWith('zh-tw') || nav.startsWith('zh-hk') || nav.startsWith('zh-mo')) return 'zh-TW'
if (nav.startsWith('zh')) return 'zh-CN'
if (nav.startsWith('pt')) return 'pt'
if (nav.startsWith('en')) return 'en'
return 'zh-CN'
}
export function persistLocale(locale: AppLocale) {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch {
/* ignore */
}
if (typeof document !== 'undefined') {
document.documentElement.lang = locale
}
}
const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'zh-TW': zhTW,
en,
pt,
},
})
persistLocale(i18n.global.locale.value as AppLocale)
export default i18n
/** English — big screen */
export default {
screen: {
defaultTitle: 'Molecular Lab 3D Visualization Platform',
alarm: 'Alarms',
alarmList: 'Alarm list',
currentAlarms: 'Active alarms',
admin: 'Admin',
language: 'Language',
systemOk: 'System running normally',
monitoring: 'Threshold monitoring active',
normal: 'Normal',
pointOverLimit: 'Point over limit',
watching: 'Watching',
humidityPrefix: 'RH',
deviceCategory: 'Device categories',
areaFly: 'Area viewpoints',
overview: 'Overview',
areaNav: 'Area navigation',
clickFly: 'Click to fly',
currentArea: 'Current area',
tipCloud: 'RTEngineHD cloud render · Navigate in view · Select area below to fly',
tipLocal: 'LMB pan · RMB rotate · Wheel zoom · Select area below',
loginToControl: 'Please sign in as admin before controlling',
lightNotBound: 'Lighting facility not found; check facility bindings',
controlFailed: 'Control failed',
switchOn: 'On',
switchOff: 'Off',
switching: 'Switching…',
running: 'Running',
stopped: 'Stopped',
status: 'Status',
faceVelocity: 'Face velocity',
airflow: 'Airflow',
close: 'Close',
current: 'Current',
unknown: 'Unknown',
},
metrics: {
temp: 'Temp',
humidity: 'Humidity',
pressure: 'Pressure',
alarm: 'Alarm',
status: 'Status',
faceVelocity: 'Face velocity',
sashHeight: 'Sash height',
airflow: 'Airflow',
valveOpen: 'Valve open',
maxExhaust: 'Max exhaust',
minAirflow: 'Min airflow',
alarmCode: 'Alarm code',
area: 'Area',
type: 'Type',
door: 'Door',
todayPass: 'Passes today',
camera: 'Camera',
none: 'None',
yes: 'Yes',
no: 'No',
},
doorStatus: {
open: 'Open',
closed: 'Closed',
alarm: 'Alarm',
offline: 'Offline',
},
modes: {
home: 'Overview',
camera: 'Cameras',
access: 'Access',
pressure: 'Pressure',
lightN: 'General lighting',
lightE: 'Emergency lighting',
fridge: 'Fridges',
vav: 'Fume hoods',
e066: 'Building E 1F066 fridge',
},
areas: {
seq: 'Sequencing',
'lib-2': 'Library prep 2',
'lib-1': 'Library prep 1',
'sample-2': 'Sample prep 2',
'sample-1': 'Sample prep 1',
consult: 'Consultation',
prep: 'Prep area',
report: 'Reporting',
},
lang: {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
en: 'English',
pt: 'Português',
},
weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
}
/** Português — ecrã grande */
export default {
screen: {
defaultTitle: 'Plataforma de Visualização 3D do Laboratório Molecular',
alarm: 'Alarmes',
alarmList: 'Lista de alarmes',
currentAlarms: 'Alarmes ativos',
admin: 'Admin',
language: 'Idioma',
systemOk: 'Sistema a funcionar normalmente',
monitoring: 'Monitorização de limiares ativa',
normal: 'Normal',
pointOverLimit: 'Ponto fora do limite',
watching: 'Em atenção',
humidityPrefix: 'RH',
deviceCategory: 'Categorias de equipamentos',
areaFly: 'Vistas da área',
overview: 'Vista geral',
areaNav: 'Navegação de áreas',
clickFly: 'Clique para voar',
currentArea: 'Área atual',
tipCloud: 'Renderização RTEngineHD · Navegue na vista · Selecione a área abaixo',
tipLocal: 'Botão esquerdo pan · Direito rodar · Roda zoom · Selecione a área',
loginToControl: 'Inicie sessão como administrador antes de controlar',
lightNotBound: 'Iluminação não encontrada; verifique as ligações',
controlFailed: 'Falha no controlo',
switchOn: 'Ligado',
switchOff: 'Desligado',
switching: 'A alternar…',
running: 'A funcionar',
stopped: 'Parado',
status: 'Estado',
faceVelocity: 'Velocidade frontal',
airflow: 'Caudal de ar',
close: 'Fechar',
current: 'Atual',
unknown: 'Desconhecido',
},
metrics: {
temp: 'Temp.',
humidity: 'Humidade',
pressure: 'Pressão',
alarm: 'Alarme',
status: 'Estado',
faceVelocity: 'Vel. frontal',
sashHeight: 'Altura da janela',
airflow: 'Caudal',
valveOpen: 'Abertura da válvula',
maxExhaust: 'Exaustão máx.',
minAirflow: 'Caudal mín.',
alarmCode: 'Código de alarme',
area: 'Área',
type: 'Tipo',
door: 'Porta',
todayPass: 'Passagens hoje',
camera: 'Câmara',
none: 'Nenhum',
yes: 'Sim',
no: 'Não',
},
doorStatus: {
open: 'Aberta',
closed: 'Fechada',
alarm: 'Alarme',
offline: 'Offline',
},
modes: {
home: 'Vista geral',
camera: 'Câmaras',
access: 'Acesso',
pressure: 'Pressão',
lightN: 'Iluminação geral',
lightE: 'Iluminação de emergência',
fridge: 'Frigoríficos',
vav: 'Hotte',
e066: 'Frigorífico E 1F066',
},
areas: {
seq: 'Sequenciação',
'lib-2': 'Preparação de biblioteca 2',
'lib-1': 'Preparação de biblioteca 1',
'sample-2': 'Preparação de amostra 2',
'sample-1': 'Preparação de amostra 1',
consult: 'Consulta',
prep: 'Área de preparação',
report: 'Relatórios',
},
lang: {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
en: 'English',
pt: 'Português',
},
weekdays: ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'],
}
/** 简体中文 — 大屏 */
export default {
screen: {
defaultTitle: '分子实验室三维可视化管理平台',
alarm: '告警',
alarmList: '告警列表',
currentAlarms: '当前告警',
admin: '管理',
language: '语言',
systemOk: '系统运行正常',
monitoring: '点位阈值监测中',
normal: '正常',
pointOverLimit: '点位超限',
watching: '关注中',
humidityPrefix: 'RH',
deviceCategory: '设备分类',
areaFly: '区域飞视',
overview: '全景总览',
areaNav: '区域导航',
clickFly: '点击飞视点',
currentArea: '当前区域',
tipCloud: 'RTEngineHD 云渲染 · 在画面内操作漫游 · 底部点选区域飞视点',
tipLocal: '左键拖动 · 右键旋转 · 滚轮缩放 · 底部点选区域',
loginToControl: '请先登录管理员账号后再控制',
lightNotBound: '未找到对应照明设施,请检查设施管理绑定',
controlFailed: '控制失败',
switchOn: '开',
switchOff: '关',
switching: '切换中…',
running: '运行',
stopped: '停止',
status: '状态',
faceVelocity: '面风速',
airflow: '风量',
close: '关闭',
current: '当前',
unknown: '未知',
},
metrics: {
temp: '温度',
humidity: '湿度',
pressure: '压差',
alarm: '报警',
status: '状态',
faceVelocity: '面风速',
sashHeight: '视窗高度',
airflow: '风量',
valveOpen: '阀门开度',
maxExhaust: '最大排风',
minAirflow: '最小风量',
alarmCode: '报警码',
area: '区域',
type: '类型',
door: '门点',
todayPass: '今日通行',
camera: '摄像头',
none: '无',
yes: '是',
no: '否',
},
doorStatus: {
open: '开门',
closed: '关门',
alarm: '报警',
offline: '离线',
},
modes: {
home: '全景总览',
camera: '监控',
access: '门禁',
pressure: '压差',
lightN: '普通照明',
lightE: '应急照明',
fridge: '冰箱',
vav: '通风柜',
e066: 'E栋1F066冰箱',
},
areas: {
seq: '测序区',
'lib-2': '文库制备区2',
'lib-1': '文库制备区1',
'sample-2': '样本制备区2',
'sample-1': '样本制备区1',
consult: '咨询室',
prep: '准备区',
report: '报告室',
},
lang: {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
en: 'English',
pt: 'Português',
},
weekdays: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
}
/** 繁體中文 — 大屏 */
export default {
screen: {
defaultTitle: '分子實驗室三維可視化管理平台',
alarm: '告警',
alarmList: '告警列表',
currentAlarms: '當前告警',
admin: '管理',
language: '語言',
systemOk: '系統運行正常',
monitoring: '點位閾值監測中',
normal: '正常',
pointOverLimit: '點位超限',
watching: '關注中',
humidityPrefix: 'RH',
deviceCategory: '設備分類',
areaFly: '區域飛視',
overview: '全景總覽',
areaNav: '區域導航',
clickFly: '點擊飛視點',
currentArea: '當前區域',
tipCloud: 'RTEngineHD 雲渲染 · 在畫面內操作漫遊 · 底部點選區域飛視點',
tipLocal: '左鍵拖動 · 右鍵旋轉 · 滾輪縮放 · 底部點選區域',
loginToControl: '請先登入管理員帳號後再控制',
lightNotBound: '未找到對應照明設施,請檢查設施管理綁定',
controlFailed: '控制失敗',
switchOn: '開',
switchOff: '關',
switching: '切換中…',
running: '運行',
stopped: '停止',
status: '狀態',
faceVelocity: '面風速',
airflow: '風量',
close: '關閉',
current: '當前',
unknown: '未知',
},
metrics: {
temp: '溫度',
humidity: '濕度',
pressure: '壓差',
alarm: '報警',
status: '狀態',
faceVelocity: '面風速',
sashHeight: '視窗高度',
airflow: '風量',
valveOpen: '閥門開度',
maxExhaust: '最大排風',
minAirflow: '最小風量',
alarmCode: '報警碼',
area: '區域',
type: '類型',
door: '門點',
todayPass: '今日通行',
camera: '攝像頭',
none: '無',
yes: '是',
no: '否',
},
doorStatus: {
open: '開門',
closed: '關門',
alarm: '報警',
offline: '離線',
},
modes: {
home: '全景總覽',
camera: '監控',
access: '門禁',
pressure: '壓差',
lightN: '普通照明',
lightE: '應急照明',
fridge: '冰箱',
vav: '通風櫃',
e066: 'E棟1F066冰箱',
},
areas: {
seq: '測序區',
'lib-2': '文庫製備區2',
'lib-1': '文庫製備區1',
'sample-2': '樣本製備區2',
'sample-1': '樣本製備區1',
consult: '諮詢室',
prep: '準備區',
report: '報告室',
},
lang: {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
en: 'English',
pt: 'Português',
},
weekdays: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
}
import i18n from './index'
/** 在 setup 外(纯函数)取当前语言文案 */
export function tt(key: string, values?: Record<string, unknown>): string {
return String(i18n.global.t(key, values as never))
}
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { useClock } from '@/composables/useClock' import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather' import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale' import { useStageScale } from '@/composables/useStageScale'
import ScreenHeader from '@/components/screen/ScreenHeader.vue' import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import LanguageSwitch from '@/components/screen/LanguageSwitch.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue' import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue' import AppIcon from '@/components/icons/AppIcon.vue'
import { useScreenTitle } from '@/composables/useScreenTitle' import { useScreenTitle } from '@/composables/useScreenTitle'
...@@ -14,6 +16,7 @@ import { useScreenTitle } from '@/composables/useScreenTitle' ...@@ -14,6 +16,7 @@ import { useScreenTitle } from '@/composables/useScreenTitle'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const { time, date } = useClock() const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather() const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale() const { style } = useStageScale()
...@@ -25,13 +28,20 @@ const alarmPanelStyle = ref<Record<string, string>>({}) ...@@ -25,13 +28,20 @@ const alarmPanelStyle = ref<Record<string, string>>({})
const alarmItems = computed(() => { const alarmItems = computed(() => {
const alarms = realtime.snapshot.pointAlarms || [] const alarms = realtime.snapshot.pointAlarms || []
if (!alarms.length) { if (!alarms.length) {
return [{ level: 'calm' as const, title: '系统运行正常', sub: '点位阈值监测中', state: '正常' }] return [
{
level: 'calm' as const,
title: t('screen.systemOk'),
sub: t('screen.monitoring'),
state: t('screen.normal'),
},
]
} }
return alarms.slice(0, 12).map((a) => ({ return alarms.slice(0, 12).map((a) => ({
level: (a.level === 'red' ? 'red' : 'amber') as 'red' | 'amber', level: (a.level === 'red' ? 'red' : 'amber') as 'red' | 'amber',
title: a.message, title: a.message,
sub: a.name || '点位超限', sub: a.name || t('screen.pointOverLimit'),
state: '关注中', state: t('screen.watching'),
})) }))
}) })
...@@ -101,7 +111,7 @@ function goAdmin() { ...@@ -101,7 +111,7 @@ function goAdmin() {
<span class="place">{{ place }}</span> <span class="place">{{ place }}</span>
<span class="temp">{{ temp }}</span> <span class="temp">{{ temp }}</span>
<span class="w-text">{{ weatherText }}</span> <span class="w-text">{{ weatherText }}</span>
<span class="humid">RH:{{ humidity }}</span> <span class="humid">{{ t('screen.humidityPrefix') }}:{{ humidity }}</span>
</div> </div>
</template> </template>
<template #right> <template #right>
...@@ -109,6 +119,7 @@ function goAdmin() { ...@@ -109,6 +119,7 @@ function goAdmin() {
<span class="time">{{ time }}</span> <span class="time">{{ time }}</span>
<span class="date">{{ date }}</span> <span class="date">{{ date }}</span>
</div> </div>
<LanguageSwitch />
<div class="alarm-wrap"> <div class="alarm-wrap">
<button <button
ref="alarmBtnRef" ref="alarmBtnRef"
...@@ -120,10 +131,12 @@ function goAdmin() { ...@@ -120,10 +131,12 @@ function goAdmin() {
@click.stop="toggleAlarm" @click.stop="toggleAlarm"
> >
<span class="dot red" /> <span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span> <span>{{ t('screen.alarm') }} {{ realtime.alarmCount }}</span>
</button> </button>
</div> </div>
<button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button> <button class="tops-logout admin-entry" type="button" @click="goAdmin">
{{ t('screen.admin') }}
</button>
</template> </template>
</ScreenHeader> </ScreenHeader>
...@@ -138,12 +151,12 @@ function goAdmin() { ...@@ -138,12 +151,12 @@ function goAdmin() {
v-if="alarmOpen" v-if="alarmOpen"
class="alarm-dropdown" class="alarm-dropdown"
role="dialog" role="dialog"
aria-label="告警列表" :aria-label="t('screen.alarmList')"
:style="alarmPanelStyle" :style="alarmPanelStyle"
@click.stop @click.stop
> >
<div class="alarm-dd-head"> <div class="alarm-dd-head">
<span>当前告警</span> <span>{{ t('screen.currentAlarms') }}</span>
<b>{{ realtime.alarmCount }}</b> <b>{{ realtime.alarmCount }}</b>
</div> </div>
<div class="alarm-dd-list"> <div class="alarm-dd-list">
......
...@@ -2,8 +2,9 @@ import { createApp } from 'vue' ...@@ -2,8 +2,9 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import i18n from './i18n'
import './styles/tokens.css' import './styles/tokens.css'
import './styles/screen.css' import './styles/screen.css'
import './styles/tops-chrome.css' import './styles/tops-chrome.css'
createApp(App).use(createPinia()).use(router).mount('#app') createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment