Commit bca96c6a by James Yuan

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 83d33602
......@@ -477,6 +477,67 @@
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
......@@ -4134,6 +4195,33 @@
"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": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
......@@ -4320,6 +4408,7 @@
"pinia": "^3.0.1",
"vue": "^3.5.42",
"vue-echarts": "^8.3.2",
"vue-i18n": "^11.4.16",
"vue-router": "^4.5.0"
},
"devDependencies": {
......
......@@ -13,6 +13,7 @@
"pinia": "^3.0.1",
"vue": "^3.5.42",
"vue-echarts": "^8.3.2",
"vue-i18n": "^11.4.16",
"vue-router": "^4.5.0"
},
"devDependencies": {
......
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
......@@ -161,7 +164,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<button
class="near-close"
type="button"
aria-label="关闭"
:aria-label="t('screen.close')"
@pointerdown.stop="requestClose"
@click.stop.prevent="requestClose"
>
......@@ -194,9 +197,15 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</ul>
<div v-else class="near-switch">
<p class="near-switch-status">
当前:
{{ t('screen.current') }}:
<b :class="{ on: switchOn === true, off: switchOn === false }">
{{ switchOn == null ? '未知' : switchOn ? '开' : '关' }}
{{
switchOn == null
? t('screen.unknown')
: switchOn
? t('screen.switchOn')
: t('screen.switchOff')
}}
</b>
</p>
<div class="near-switch-actions">
......@@ -206,7 +215,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:disabled="switching"
@click="emit('control', true)"
>
开
{{ t('screen.switchOn') }}
</button>
<button
type="button"
......@@ -214,7 +223,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:disabled="switching"
@click="emit('control', false)"
>
关
{{ t('screen.switchOff') }}
</button>
</div>
<p v-if="switchError" class="near-switch-err">{{ switchError }}</p>
......
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode'
import { SCREEN_AREAS } from '@/data/rooms'
......@@ -38,6 +39,7 @@ const props = withDefaults(
const realtime = useRealtimeStore()
const screenMode = useScreenModeStore()
const auth = useAuthStore()
const { t } = useI18n()
const { mode: activeMode } = storeToRefs(screenMode)
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
......@@ -344,7 +346,7 @@ function findLightSensor(marker: SceneMarkerHit, markerId: string | null): Senso
async function onLightControl(on: boolean) {
if (!nearSensorId.value) return
if (!auth.token) {
nearSwitchError.value = '请先登录管理员账号后再控制'
nearSwitchError.value = t('screen.loginToControl')
return
}
nearSwitching.value = true
......@@ -369,7 +371,7 @@ async function onLightControl(on: boolean) {
// false:只对变化的灯 setLightVisible,避免整表 clear 造成闪一下看不见
window.setTimeout(() => syncLightEffects(), 0)
} catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : '控制失败'
nearSwitchError.value = e instanceof Error ? e.message : t('screen.controlFailed')
} finally {
nearSwitching.value = false
}
......@@ -534,7 +536,7 @@ function onMarkerClick(payload: {
nearLines.value = []
nearSensorId.value = light?.id || light?.code || null
nearSwitchOn.value = light?.on ?? null
nearSwitchError.value = light ? '' : '未找到对应照明设施,请检查设施管理绑定'
nearSwitchError.value = light ? '' : t('screen.lightNotBound')
openNearDialog(light?.name || marker.name || '照明控制', markerId || marker.id, raw)
return
}
......@@ -760,7 +762,9 @@ onBeforeUnmount(() => {
<div v-if="props.layout !== 'fill'" class="vp-topbar">
<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="view">{{ modeLabel }}</span>
</div>
......@@ -768,7 +772,7 @@ onBeforeUnmount(() => {
<!-- 全屏:右侧设备分类(只切图标)+ 底栏区域飞视 -->
<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
v-for="item in DEVICE_MENU"
:key="item.id"
......@@ -777,12 +781,12 @@ onBeforeUnmount(() => {
:class="{ active: item.id === activeMode }"
@click="onSelectMode(item.id)"
>
<span class="name">{{ item.label }}</span>
<span class="name">{{ t(`modes.${item.id}`) }}</span>
</button>
</div>
<!-- 底栏:全景总览在测序区前面,其后为各区域飞视 -->
<div class="vp-mode-bar" aria-label="区域飞视">
<div class="vp-mode-bar" :aria-label="t('screen.areaFly')">
<button
type="button"
class="vp-mode-item"
......@@ -790,7 +794,7 @@ onBeforeUnmount(() => {
@click="onSelectOverview"
>
<AppIcon name="pin" class-name="room-ico" />
<span>全景总览</span>
<span>{{ t('screen.overview') }}</span>
</button>
<button
v-for="r in areaList"
......@@ -801,7 +805,7 @@ onBeforeUnmount(() => {
@click="onSelectArea(r.id)"
>
<AppIcon name="pin" class-name="room-ico" />
<span>{{ r.name }}</span>
<span>{{ t(`areas.${r.id}`) }}</span>
</button>
</div>
</Teleport>
......@@ -809,8 +813,8 @@ onBeforeUnmount(() => {
<div class="vp-room-head">
<AppIcon name="pin" />
<div>
<div class="vp-room-label">区域导航</div>
<div class="vp-room-sub">点击飞视点</div>
<div class="vp-room-label">{{ t('screen.areaNav') }}</div>
<div class="vp-room-sub">{{ t('screen.clickFly') }}</div>
</div>
</div>
<div class="vp-room-list">
......@@ -821,7 +825,7 @@ onBeforeUnmount(() => {
@click="onSelectOverview"
>
<AppIcon name="pin" class-name="room-ico" />
<span class="name">全景总览</span>
<span class="name">{{ t('screen.overview') }}</span>
</button>
<button
v-for="r in areaList"
......@@ -832,7 +836,7 @@ onBeforeUnmount(() => {
@click="onSelectArea(r.id)"
>
<AppIcon name="pin" class-name="room-ico" />
<span class="name">{{ r.name }}</span>
<span class="name">{{ t(`areas.${r.id}`) }}</span>
</button>
</div>
</div>
......@@ -864,12 +868,8 @@ onBeforeUnmount(() => {
<div v-if="props.layout !== 'fill'" class="vp-tip">
<AppIcon name="hand" class-name="hand" />
<span v-if="useCloudFrame">
RTEngineHD 云渲染 · 在画面内操作漫游 · 底部点选区域飞视点
</span>
<span v-else>
<b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 底部点选区域
</span>
<span v-if="useCloudFrame">{{ t('screen.tipCloud') }}</span>
<span v-else>{{ t('screen.tipLocal') }}</span>
</div>
<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">
import { useI18n } from 'vue-i18n'
import headerBar from '@/assets/screen/header-bar.png'
defineProps<{
title?: string
}>()
const { t } = useI18n()
</script>
<template>
<header class="screen-header-hy">
<img class="banner" :src="headerBar" alt="" />
<h1>{{ title || '分子实验室三维可视化管理平台' }}</h1>
<h1>{{ title || t('screen.defaultTitle') }}</h1>
<div class="slot-left">
<slot name="left" />
</div>
......
......@@ -273,10 +273,10 @@ function iconSrc(file: string) {
class="icon-item"
:class="{ active: selectedIcon === f }"
:title="f"
:aria-label="f"
@click="selectedIcon = f"
>
<img :src="iconSrc(f)" :alt="f" loading="lazy" />
<span>{{ f.replace(/\.png$/i, '') }}</span>
</button>
</div>
<div class="icon-current">
......@@ -404,34 +404,26 @@ function iconSrc(file: string) {
overflow: auto;
padding: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
align-content: start;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
justify-content: center;
min-height: 88px;
padding: 10px 6px;
border-radius: 6px;
border: 1px solid rgba(0, 247, 255, 0.16);
background: rgba(8, 28, 56, 0.45);
color: #9ec9e0;
cursor: pointer;
font-size: 10px;
}
.icon-item img {
width: 36px;
height: 36px;
width: 52px;
height: 52px;
object-fit: contain;
}
.icon-item span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-item:hover {
border-color: rgba(0, 247, 255, 0.45);
}
......
import { onMounted, onUnmounted, ref } from 'vue'
const weekMap = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
function p2(n: number) {
return n < 10 ? `0${n}` : `${n}`
}
export function useClock() {
const { tm, locale } = useI18n()
const time = ref('--:--:--')
const date = ref('')
let timer: number | undefined
......@@ -14,9 +14,13 @@ export function useClock() {
function tick() {
const d = new Date()
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(() => {
tick()
timer = window.setInterval(tick, 1000)
......
......@@ -374,6 +374,20 @@ export function useRtengineBridge(opts: {
const pendingFlies: string[] = []
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 被排队吞掉 */
async function ensureBookmarks() {
......@@ -408,34 +422,41 @@ export function useRtengineBridge(opts: {
}
}
/** 同域直调 $gisObj.flyToBookmark("测序区")(与引擎 console 一致) */
/**
* 同域直调 $gisObj.flyToBookmark。
* 过早调用时引擎会排队并在书签注册后自动补飞一次;这里不再 600ms/2s 重试,
* 否则每次刷新会连播多次 Changing。
*/
function flyToBookmark(name: string, options?: Record<string, unknown>) {
const id = String(name || '').trim()
if (!id) return
if (!id || recentlyFlew(id)) return
const tryDirect = (): boolean => {
const gis = gisObj()
if (!gis || typeof gis.flyToBookmark !== 'function') return false
if (gis && typeof gis.flyToBookmark === 'function') {
try {
const ret = options ? gis.flyToBookmark(id, options) : gis.flyToBookmark(id)
if (ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued) {
if (!pendingFlies.includes(id)) pendingFlies.push(id)
}
return true
const queued = !!(ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued)
// queued 也算已交给引擎,注册完成后它会自己补飞,不要再打第二次
rememberFly(id)
if (queued) return
return
} catch {
return false
/* 直调失败再排队一次 */
}
}
if (tryDirect()) return
if (!pendingFlies.includes(id)) pendingFlies.push(id)
send('flyToBookmark', id)
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() {
......@@ -813,21 +834,15 @@ export function useRtengineBridge(opts: {
}
let readyNotified = false
let flyFlushed = false
function markReady() {
if (readyNotified) {
// 已就绪:只冲未飞完的视点,不再反复 ensureBookmarks
if (pendingFlies.length) flushPendingFlies()
return
}
if (readyNotified) return
readyNotified = true
ready.value = true
opts.onReady?.()
if (flyFlushed) return
flyFlushed = true
void ensureBookmarks().then(() => flushPendingFlies())
// 书签 / $gisObj 可能稍晚就绪,再冲几次排队飞视
window.setTimeout(() => {
void ensureBookmarks().then(() => flushPendingFlies())
}, 800)
window.setTimeout(() => flushPendingFlies(), 2500)
}
function onMessage(ev: MessageEvent) {
......
import { watch, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode'
import {
......@@ -18,6 +19,7 @@ import {
type RtengineMarker,
} from '@/composables/useRtengineBridge'
import type { RealtimeSnapshot, SensorSnapshot } from '@/types'
import { tt } from '@/i18n/t'
const MARKER_LEADER = {
leader: true as const,
......@@ -64,27 +66,29 @@ function buildText(def: ModePoiDef, snap: RealtimeSnapshot): string {
}
if (bind.kind === 'vav') {
const v = snap.vav.find((x) => x.id === bind.deviceId)
const run = v?.running ? '运行' : '停止'
return `状态:${run}\n面风速:${fmtNum(v?.faceVelocity)}m/s\n风量:${fmtNum(v?.airflow, 0)}`
const run = v?.running ? tt('screen.running') : tt('screen.stopped')
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') {
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
}
function sensorText(s: SensorSnapshot): string {
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') {
const v = s.values || { temp: null, humidity: null, pressure: null }
return `${fmtNum(v.temp)}℃\n${fmtNum(v.humidity, 0)}%`
}
if (s.category === 'vav') {
const run = s.on == null ? '—' : s.on ? '运行' : '停止'
return `状态:${run}\n面风速:${fmtNum(s.vav?.faceVelocity)}m/s\n风量:${fmtNum(s.vav?.airflow, 0)}`
const run =
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') {
return s.name
......@@ -190,6 +194,7 @@ function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarke
export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cloudUrl: string) {
const modeStore = useScreenModeStore()
const realtime = useRealtimeStore()
const { locale } = useI18n()
const { mode } = storeToRefs(modeStore)
let activeIds: string[] = []
......@@ -409,6 +414,12 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
() => scheduleSync(),
)
// 切换语言后刷新场景标注文案
watch(locale, () => {
lastTextSig = ''
scheduleSync()
})
return {
clearModeMarkers,
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">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale'
import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import LanguageSwitch from '@/components/screen/LanguageSwitch.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue'
import { useScreenTitle } from '@/composables/useScreenTitle'
......@@ -14,6 +16,7 @@ import { useScreenTitle } from '@/composables/useScreenTitle'
const auth = useAuthStore()
const realtime = useRealtimeStore()
const router = useRouter()
const { t } = useI18n()
const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale()
......@@ -25,13 +28,20 @@ const alarmPanelStyle = ref<Record<string, string>>({})
const alarmItems = computed(() => {
const alarms = realtime.snapshot.pointAlarms || []
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) => ({
level: (a.level === 'red' ? 'red' : 'amber') as 'red' | 'amber',
title: a.message,
sub: a.name || '点位超限',
state: '关注中',
sub: a.name || t('screen.pointOverLimit'),
state: t('screen.watching'),
}))
})
......@@ -101,7 +111,7 @@ function goAdmin() {
<span class="place">{{ place }}</span>
<span class="temp">{{ temp }}</span>
<span class="w-text">{{ weatherText }}</span>
<span class="humid">RH:{{ humidity }}</span>
<span class="humid">{{ t('screen.humidityPrefix') }}:{{ humidity }}</span>
</div>
</template>
<template #right>
......@@ -109,6 +119,7 @@ function goAdmin() {
<span class="time">{{ time }}</span>
<span class="date">{{ date }}</span>
</div>
<LanguageSwitch />
<div class="alarm-wrap">
<button
ref="alarmBtnRef"
......@@ -120,10 +131,12 @@ function goAdmin() {
@click.stop="toggleAlarm"
>
<span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span>
<span>{{ t('screen.alarm') }} {{ realtime.alarmCount }}</span>
</button>
</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>
</ScreenHeader>
......@@ -138,12 +151,12 @@ function goAdmin() {
v-if="alarmOpen"
class="alarm-dropdown"
role="dialog"
aria-label="告警列表"
:aria-label="t('screen.alarmList')"
:style="alarmPanelStyle"
@click.stop
>
<div class="alarm-dd-head">
<span>当前告警</span>
<span>{{ t('screen.currentAlarms') }}</span>
<b>{{ realtime.alarmCount }}</b>
</div>
<div class="alarm-dd-list">
......
......@@ -2,8 +2,9 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import './styles/tokens.css'
import './styles/screen.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