Commit 86a44534 by James Yuan

feat: 补全大屏 i18n,并修复繁体字体回退忽大忽小

总览与各子页文案接入四语;繁体改用 TC 字体栈并关闭中文伪斜体。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent bca96c6a
......@@ -50,7 +50,7 @@ export type ScreenDashboard = {
/** 设备分类占比(饼图) */
categoryPie: { name: string; value: number; category: string }[]
/** 在线状态占比(饼图) */
statusPie: { name: string; value: number }[]
statusPie: { key: string; name: string; value: number }[]
/** 各电表用电量(柱状) */
powerBars: { name: string; value: number | null }[]
/** 各房间压差(柱状) */
......@@ -100,7 +100,13 @@ export type ScreenDashboard = {
coolingKwh: number
}
/** PAU 关键运行点(无假数) */
pauCards: { label: string; value: number | null; unit: string; ok: boolean | null }[]
pauCards: {
key: string
label: string
value: number | null
unit: string
ok: boolean | null
}[]
/** VAV 运行摘要 */
vavBars: { name: string; airflow: number | null; faceVelocity: number | null; running: boolean }[]
}
......@@ -212,10 +218,10 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
}
}
const statusPie = [
{ name: '在线', value: online },
{ name: '告警', value: alarm },
{ name: '离线', value: offline },
{ name: '未知', value: unknown },
{ key: 'online', name: '在线', value: online },
{ key: 'alarm', name: '告警', value: alarm },
{ key: 'offline', name: '离线', value: offline },
{ key: 'unknown', name: '未知', value: unknown },
].filter((x) => x.value > 0)
const powerBars = enabled
......@@ -269,18 +275,44 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
.flatMap((d) => byDevice.get(d.id) || [])
const pauCards = [
{
key: 'supplyTemp',
label: '送风温度',
value: engOf(pauPts, /送风温度显示|送风温度(?!校准|设定)/),
unit: '℃',
ok: null as boolean | null,
},
{ label: '送风湿度', value: engOf(pauPts, /送风湿度显示|送风湿度(?!校准|设定)/), unit: '%', ok: null },
{ label: '送风压力', value: engOf(pauPts, /PAU201送风压力显示|送风压力显示|送风压力(?!设定)/), unit: 'Pa', ok: null },
{ label: '排风压力', value: engOf(pauPts, /EAF201排风压力显示|排风压力显示/), unit: 'Pa', ok: null },
{ label: '新风温度', value: engOf(pauPts, /新风温度显示|新风温度(?!设定)/), unit: '℃', ok: null },
{
key: 'supplyHumidity',
label: '送风湿度',
value: engOf(pauPts, /送风湿度显示|送风湿度(?!校准|设定)/),
unit: '%',
ok: null,
},
{
key: 'supplyPressure',
label: '送风压力',
value: engOf(pauPts, /PAU201送风压力显示|送风压力显示|送风压力(?!设定)/),
unit: 'Pa',
ok: null,
},
{
key: 'exhaustPressure',
label: '排风压力',
value: engOf(pauPts, /EAF201排风压力显示|排风压力显示/),
unit: 'Pa',
ok: null,
},
{
key: 'freshAirTemp',
label: '新风温度',
value: engOf(pauPts, /新风温度显示|新风温度(?!设定)/),
unit: '℃',
ok: null,
},
(() => {
const v = engOf(pauPts, /系统启动信号|^开机$|PAU201送风机启停/)
return {
key: 'systemStart',
label: '系统启动',
value: v,
unit: '',
......
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ScenePoint } from '@/composables/useRtengineBridge'
export type ScenePropertyField = {
......@@ -21,6 +22,8 @@ const emit = defineEmits<{
close: []
play: []
}>()
const { t } = useI18n()
</script>
<template>
......@@ -33,7 +36,7 @@ const emit = defineEmits<{
<h3>{{ title }}</h3>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
</div>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
<button class="btn ghost" type="button" @click="emit('close')">{{ t('screen.close') }}</button>
</header>
<div class="prop-body">
......@@ -43,10 +46,10 @@ const emit = defineEmits<{
<b class="value">{{ f.value }}</b>
</div>
</div>
<div v-else class="empty">暂无属性</div>
<div v-else class="empty">{{ t('scene.noProps') }}</div>
<div v-if="point" class="coords">
<span>坐标</span>
<span>{{ t('scene.coords') }}</span>
<code>
X {{ point.x.toFixed(6) }} · Y {{ point.y.toFixed(6) }} · Z {{ point.z.toFixed(2) }}
</code>
......@@ -54,7 +57,7 @@ const emit = defineEmits<{
</div>
<footer v-if="showPlay" class="prop-foot">
<button class="btn primary" type="button" @click="emit('play')">打开视频</button>
<button class="btn primary" type="button" @click="emit('play')">{{ t('scene.openVideo') }}</button>
</footer>
</aside>
</div>
......
......@@ -68,7 +68,7 @@ const activeAreaId = ref('')
const cameras = ref<VideoDevice[]>([])
/** Marker 附近弹窗(400×240) */
const nearOpen = ref(false)
const nearTitle = ref('监控预览')
const nearTitle = ref('')
const nearKind = ref<'video' | 'values' | 'switch'>('video')
const nearLines = ref<{ label: string; value: string }[]>([])
const nearHit = ref<SceneMarkerHit | null>(null)
......@@ -504,7 +504,7 @@ function resolveNearTitle(
if (label && !looksLikeRawId(label) && !idKeys.has(normalizeCamKey(label))) {
return label
}
return '监控预览'
return t('screen.camPreview')
}
function onMarkerClick(payload: {
......@@ -537,7 +537,7 @@ function onMarkerClick(payload: {
nearSensorId.value = light?.id || light?.code || null
nearSwitchOn.value = light?.on ?? null
nearSwitchError.value = light ? '' : t('screen.lightNotBound')
openNearDialog(light?.name || marker.name || '照明控制', markerId || marker.id, raw)
openNearDialog(light?.name || marker.name || t('screen.lightControl'), markerId || marker.id, raw)
return
}
const readout = markerReadout(marker, realtime.snapshot)
......@@ -678,9 +678,9 @@ const selected = computed(() => {
})
const modeLabel = computed(() => {
if (mode === 'rtengine') return 'RTEngineHD 云渲染'
if (mode === 'ue-stream') return 'UE 推流模式'
return '实景扫描模型 · 自由视角'
if (mode === 'rtengine') return t('screen.modeRtengine')
if (mode === 'ue-stream') return t('screen.modeUeStream')
return t('screen.modeLocal')
})
/** 等价调用 iframe 内 $gisObj.flyToBookmark("测序区");书签名须与场景一致 */
......
......@@ -130,8 +130,8 @@ const fillId = `titleFill-${useId()}`
font-family: var(--title-font);
color: #f4feff;
font-size: 15px;
font-weight: 400;
letter-spacing: 1px;
font-weight: 500;
letter-spacing: var(--cjk-tracking, 1px);
text-shadow: 0 0 8px rgba(0, 247, 255, 0.35);
white-space: nowrap;
}
......
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import headerBar from '@/assets/screen/header-bar.png'
......@@ -6,13 +7,15 @@ defineProps<{
title?: string
}>()
const { t } = useI18n()
const { t, locale } = useI18n()
/** 中文标题不用伪斜体,避免繁体缺字/合成字形忽大忽小 */
const cjkTitle = computed(() => String(locale.value).startsWith('zh'))
</script>
<template>
<header class="screen-header-hy">
<img class="banner" :src="headerBar" alt="" />
<h1>{{ title || t('screen.defaultTitle') }}</h1>
<h1 :class="{ 'is-cjk': cjkTitle }">{{ title || t('screen.defaultTitle') }}</h1>
<div class="slot-left">
<slot name="left" />
</div>
......@@ -51,7 +54,7 @@ h1 {
font-family: var(--title-font);
font-size: clamp(16px, 1.7vw, 30px);
font-weight: 900;
font-synthesis: style;
font-synthesis: none;
letter-spacing: clamp(2px, 0.35vw, 6px);
color: #f4feff;
font-style: italic;
......@@ -61,6 +64,11 @@ h1 {
overflow: hidden;
text-overflow: ellipsis;
}
h1.is-cjk {
font-style: normal;
letter-spacing: 0.06em;
font-weight: 800;
}
.slot-left,
.slot-right {
position: absolute;
......
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api/http'
const REFRESH_MS = 15 * 60 * 1000
......@@ -15,24 +16,37 @@ type MacauWeatherDto = {
* @see https://www.smg.gov.mo/zh
*/
export function useMacauWeather() {
const place = ref('澳门')
const { t, locale } = useI18n()
const place = ref(t('weather.place'))
const temp = ref('--')
const text = ref('获取中')
const text = ref(t('weather.loading'))
const humidity = ref('--')
let timer: number | undefined
let lastOk = false
async function fetchWeather() {
try {
const data = await api<MacauWeatherDto>('/api/weather/macau')
place.value = data.place || '澳门'
place.value = data.place || t('weather.place')
temp.value = data.temp || '--'
text.value = data.text || '—'
humidity.value = data.humidity || '--'
lastOk = true
} catch {
text.value = '暂无数据'
text.value = t('weather.empty')
if (!lastOk) place.value = t('weather.place')
}
}
watch(locale, () => {
if (!lastOk) {
place.value = t('weather.place')
text.value = text.value === '—' ? t('weather.empty') : t('weather.loading')
} else if (!place.value) {
place.value = t('weather.place')
}
})
onMounted(() => {
void fetchWeather()
timer = window.setInterval(() => void fetchWeather(), REFRESH_MS)
......
import { onBeforeUnmount, onMounted, shallowRef, ref } from 'vue'
import { api } from '@/api/http'
import { tt } from '@/i18n/t'
import type { ScreenDashboard } from '@/types/screen'
const emptyDigits: ScreenDashboard['digits'] = {
......@@ -68,7 +69,7 @@ export function useScreenDashboard(pollMs = 5000) {
error.value = ''
} catch (e) {
if (!data.value) {
error.value = e instanceof Error ? e.message : '大屏数据加载失败'
error.value = e instanceof Error ? e.message : tt('screen.loadFailed')
}
} finally {
loading.value = false
......
......@@ -30,15 +30,21 @@ export function detectLocale(): AppLocale {
return 'zh-CN'
}
/** 同步 html lang / data-locale,驱动繁体字体栈与其它语言样式 */
export function applyDocumentLocale(locale: AppLocale) {
if (typeof document === 'undefined') return
const root = document.documentElement
root.lang = locale
root.dataset.locale = locale
}
export function persistLocale(locale: AppLocale) {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch {
/* ignore */
}
if (typeof document !== 'undefined') {
document.documentElement.lang = locale
}
applyDocumentLocale(locale)
}
const i18n = createI18n({
......
......@@ -4,3 +4,12 @@ import i18n from './index'
export function tt(key: string, values?: Record<string, unknown>): string {
return String(i18n.global.t(key, values as never))
}
/** 按当前界面语言格式化时间 */
export function formatLocaleTime(value: string | number | Date | null | undefined): string {
if (value == null || value === '') return '—'
const loc = String(i18n.global.locale.value)
const tag =
loc === 'zh-CN' ? 'zh-CN' : loc === 'zh-TW' ? 'zh-TW' : loc === 'pt' ? 'pt-PT' : 'en-US'
return new Date(value).toLocaleString(tag, { hour12: false })
}
......@@ -36,15 +36,15 @@ const router = createRouter({
path: 'overview',
name: 'overview',
component: () => import('@/views/overview/OverviewView.vue'),
meta: { title: '全景总览', public: true },
meta: { titleKey: 'modes.home', public: true },
},
{
path: 'roam',
name: 'roam',
component: () => import('@/views/stub/StubView.vue'),
meta: {
title: '漫游巡检',
desc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。',
titleKey: 'pages.roamTitle',
descKey: 'pages.roamDesc',
public: true,
},
},
......@@ -52,49 +52,49 @@ const router = createRouter({
path: 'vav',
name: 'vav',
component: () => import('@/views/vav/VavView.vue'),
meta: { title: '通风柜 VAV', public: true },
meta: { titleKey: 'modes.vav', public: true },
},
{
path: 'pressure',
name: 'pressure',
component: () => import('@/views/pressure/PressureView.vue'),
meta: { title: '房间压差', public: true },
meta: { titleKey: 'modes.pressure', public: true },
},
{
path: 'hvac',
name: 'hvac',
component: () => import('@/views/hvac/HvacView.vue'),
meta: { title: '空调排风', public: true },
meta: { titleKey: 'pages.hvacTitle', public: true },
},
{
path: 'video',
name: 'video',
component: () => import('@/views/video/VideoView.vue'),
meta: { title: '视频监控', public: true },
meta: { titleKey: 'modes.camera', public: true },
},
{
path: 'access',
name: 'access',
component: () => import('@/views/access/AccessView.vue'),
meta: { title: '门禁系统', public: true },
meta: { titleKey: 'modes.access', public: true },
},
{
path: 'cold',
name: 'cold',
component: () => import('@/views/cold/ColdView.vue'),
meta: { title: '冷链监测', public: true },
meta: { titleKey: 'modes.fridge', public: true },
},
{
path: 'history',
name: 'history',
component: () => import('@/views/history/HistoryView.vue'),
meta: { title: '历史曲线', public: true },
meta: { titleKey: 'pages.historyTitle', public: true },
},
{
path: 'light',
name: 'light',
component: () => import('@/views/light/LightView.vue'),
meta: { title: '智能照明', public: true },
meta: { titleKey: 'modes.lightN', public: true },
},
],
},
......
......@@ -24,9 +24,12 @@
--alert: #ff4d4f;
--ok: #52c41a;
--warn: #faad14;
--title-font: 'AlibabaPuHuiTi-3-115-Black', 'Noto Sans SC', 'PingFang SC', sans-serif;
--body-font: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/* 默认简体栈;繁体/其它语言由 html[lang] 覆盖,避免缺字回退忽大忽小 */
--title-font: 'AlibabaPuHuiTi-3-115-Black', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei',
sans-serif;
--body-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
--digital-font: 'Share Tech Mono', ui-monospace, monospace;
--cjk-tracking: 0.02em;
/* Backward-compatible aliases used by existing views */
--bg: var(--screen-bg);
......@@ -54,10 +57,29 @@ html, body, #app { width: 100%; height: 100%; overflow: hidden; background: var(
body {
font-family: var(--body-font);
font-weight: 600;
letter-spacing: 0.02em;
letter-spacing: var(--cjk-tracking);
color: var(--text);
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
font-synthesis: none;
}
/* 繁体:统一 TC 字体,禁用合成斜体/伪粗,避免简体字体缺字回退导致忽大忽小 */
html[lang='zh-TW'] {
--title-font: 'PingFang TC', 'Noto Sans TC', 'Microsoft JhengHei', 'Heiti TC', 'PingFang SC',
sans-serif;
--body-font: 'PingFang TC', 'Noto Sans TC', 'Microsoft JhengHei', 'Heiti TC', 'PingFang SC',
sans-serif;
--cjk-tracking: 0;
}
html[lang='zh-TW'] body {
font-weight: 500;
}
html[lang='en'],
html[lang='pt'] {
--title-font: 'AlibabaPuHuiTi-3-115-Black', 'Helvetica Neue', Arial, sans-serif;
--body-font: 'Helvetica Neue', Arial, 'PingFang SC', sans-serif;
--cjk-tracking: 0.01em;
}
button, input, select, textarea { font: inherit; }
::-webkit-scrollbar { width: 6px; height: 6px; }
......
......@@ -34,7 +34,7 @@ export type ScreenDashboard = {
ts: number
snapshot: RealtimeSnapshot
categoryPie: { name: string; value: number; category: string }[]
statusPie: { name: string; value: number }[]
statusPie: { key?: string; name: string; value: number }[]
powerBars: { name: string; value: number | null }[]
pressureBars: { name: string; value: number | null; ok: boolean }[]
envBubbles: {
......@@ -60,6 +60,12 @@ export type ScreenDashboard = {
waterM3: number
coolingKwh: number
}
pauCards: { label: string; value: number | null; unit: string; ok: boolean | null }[]
pauCards: {
key?: string
label: string
value: number | null
unit: string
ok: boolean | null
}[]
vavBars: { name: string; airflow: number | null; faceVelocity: number | null; running: boolean }[]
}
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
const { t, locale } = useI18n()
const auth = useAuthStore()
const realtime = useRealtimeStore()
function fmt(t: string) {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
function fmt(time: string) {
const tag =
locale.value === 'zh-CN'
? 'zh-CN'
: locale.value === 'zh-TW'
? 'zh-TW'
: locale.value === 'pt'
? 'pt-PT'
: 'en-US'
return new Date(time).toLocaleString(tag, { hour12: false })
}
function doorStatus(status: string) {
if (status === 'open') return t('pages.doorOpen')
if (status === 'closed') return t('pages.doorClosed')
if (status === 'alarm') return t('doorStatus.alarm')
if (status === 'offline') return t('doorStatus.offline')
return status
}
function eventResult(result: string) {
if (result === 'success') return t('pages.accessSuccess')
if (result === 'denied') return t('pages.accessDenied')
if (result === 'alarm') return t('doorStatus.alarm')
return result
}
async function openDoor(doorId: string) {
......@@ -25,41 +50,52 @@ async function openDoor(doorId: string) {
<div class="page-fill" style="flex-direction:row">
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>门禁状态</h3></div>
<div class="badge">今日通行 {{ realtime.snapshot.access.todayPassCount }}</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.accessTitle') }}</h3></div>
<div class="badge">
{{ t('pages.accessToday', { n: realtime.snapshot.access.todayPassCount }) }}
</div>
</div>
<table class="table">
<thead>
<tr><th>门点</th><th>状态</th><th>操作</th></tr>
<tr>
<th>{{ t('pages.accessDoor') }}</th>
<th>{{ t('screen.status') }}</th>
<th>{{ t('common.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="d in realtime.snapshot.access.doors" :key="d.id">
<td>{{ d.name }}</td>
<td>{{ { open: '开启', closed: '关闭', alarm: '报警', offline: '离线' }[d.status] }}</td>
<td>{{ doorStatus(d.status) }}</td>
<td>
<button class="btn" :disabled="!auth.isAdmin" @click="openDoor(d.id)">远程开门</button>
<button class="btn" :disabled="!auth.isAdmin" @click="openDoor(d.id)">
{{ t('pages.accessOpenRemote') }}
</button>
</td>
</tr>
</tbody>
</table>
<p style="margin-top:12px;font-size:12px;color:var(--label)">
协议:海康 HCNetSDK(Windows 桥接)。一期为 Mock,二期接入桥接服务。
</p>
<p style="margin-top:12px;font-size:12px;color:var(--label)">{{ t('pages.accessFoot') }}</p>
</section>
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通行记录</h3></div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.accessEvents') }}</h3></div>
</div>
<table class="table">
<thead>
<tr><th>时间</th><th>门点</th><th>人员</th><th>结果</th></tr>
<tr>
<th>{{ t('pages.accessTime') }}</th>
<th>{{ t('pages.accessDoor') }}</th>
<th>{{ t('pages.accessPerson') }}</th>
<th>{{ t('pages.accessResult') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="e in realtime.snapshot.access.recentEvents" :key="e.id">
<td>{{ fmt(e.time) }}</td>
<td>{{ e.doorName }}</td>
<td>{{ e.person }}</td>
<td>{{ { success: '成功', denied: '拒绝', alarm: '报警' }[e.result] }}</td>
<td>{{ eventResult(e.result) }}</td>
</tr>
</tbody>
</table>
......
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { useRealtimeStore } from '@/stores/realtime'
import { multiLineOption } from '@/utils/historyChart'
const { t } = useI18n()
const realtime = useRealtimeStore()
const series = ref<{ id: string; name?: string; points: { t: number; v: number }[] }[]>([])
const humidity = ref<{ id: string; name?: string; points: { t: number; v: number }[] }[]>([])
......@@ -17,14 +19,16 @@ const humidityOption = computed(() => multiLineOption(humidity.value, { unit: '%
async function load() {
loading.value = true
try {
const [t, h] = await Promise.all([
api<{ series: typeof series.value }>('/api/realtime/history?source=fridge&metric=temp&hours=24&bucketMinutes=15'),
const [tempRes, humidityRes] = await Promise.all([
api<{ series: typeof series.value }>(
'/api/realtime/history?source=fridge&metric=temp&hours=24&bucketMinutes=15',
),
api<{ series: typeof humidity.value }>(
'/api/realtime/history?source=fridge&metric=humidity&hours=24&bucketMinutes=15',
),
])
series.value = t.series || []
humidity.value = h.series || []
series.value = tempRes.series || []
humidity.value = humidityRes.series || []
} finally {
loading.value = false
}
......@@ -43,24 +47,33 @@ onBeforeUnmount(() => {
<div class="page-fill cold-page">
<section class="panel" style="flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>冷链监测 · 历史曲线</h3></div>
<div class="badge">E栋 · 近 24h</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.coldTitle') }}</h3></div>
<div class="badge">{{ t('pages.coldBadge') }}</div>
</div>
<div class="charts">
<div class="chart-card">
<div class="chart-title">温度 ℃</div>
<EChart :option="tempOption" :loading="loading" empty-text="温度历史积累中" />
<div class="chart-title">{{ t('pages.coldTemp') }}</div>
<EChart :option="tempOption" :loading="loading" :empty-text="t('pages.coldTempEmpty')" />
</div>
<div class="chart-card">
<div class="chart-title">湿度 %RH</div>
<EChart :option="humidityOption" :loading="loading" empty-text="湿度历史积累中" />
<div class="chart-title">{{ t('pages.coldHumidity') }}</div>
<EChart
:option="humidityOption"
:loading="loading"
:empty-text="t('pages.coldHumidityEmpty')"
/>
</div>
</div>
<table class="table">
<thead>
<tr><th>设备</th><th>温度 (℃)</th><th>湿度 (%)</th><th>状态</th></tr>
<tr>
<th>{{ t('common.device') }}</th>
<th>{{ t('pages.colTemp') }}</th>
<th>{{ t('pages.colHumidity') }}</th>
<th>{{ t('screen.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="f in realtime.snapshot.fridges" :key="f.id">
......@@ -69,7 +82,7 @@ onBeforeUnmount(() => {
{{ f.temp.toFixed(1) }} ℃
</td>
<td>{{ f.humidity }} %</td>
<td>{{ f.temp > -19 ? '超限' : '正常' }}</td>
<td>{{ f.temp > -19 ? t('status.overLimit') : t('common.normal') }}</td>
</tr>
</tbody>
</table>
......@@ -78,7 +91,10 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.cold-page { display: flex; min-height: 0; }
.cold-page {
display: flex;
min-height: 0;
}
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
......
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { multiLineOption } from '@/utils/historyChart'
const { t } = useI18n()
type CatalogItem = {
source: string
metric: string
......@@ -59,7 +62,7 @@ const chartOption = computed(() =>
const summary = computed(() => {
const n = series.value.length
const pts = series.value.reduce((s, x) => s + x.points.length, 0)
return `${n} 条序列 · ${pts} 个聚合点 · 近 ${hours.value}h`
return t('pages.historySummary', { n, pts, hours: hours.value })
})
async function loadCatalog() {
......@@ -73,7 +76,7 @@ async function loadCatalog() {
metric.value = catalog.value[0].metric
}
} catch (e) {
error.value = e instanceof Error ? e.message : '目录加载失败'
error.value = e instanceof Error ? e.message : t('pages.historyCatalogFail')
} finally {
loading.value = false
}
......@@ -97,7 +100,7 @@ async function loadSeries() {
const res = await api<{ series: HistSeries[] }>(`/api/realtime/history?${q}`)
series.value = res.series || []
} catch (e) {
error.value = e instanceof Error ? e.message : '曲线加载失败'
error.value = e instanceof Error ? e.message : t('pages.historySeriesFail')
series.value = []
} finally {
loading.value = false
......@@ -122,68 +125,70 @@ onMounted(async () => {
<div class="page-fill history-page">
<section class="panel history-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>历史数据与曲线</h3></div>
<div class="badge">时序预聚合 · {{ summary }}</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.historyTitle') }}</h3></div>
<div class="badge">{{ t('pages.historyBadge', { summary }) }}</div>
</div>
<div class="filters">
<label>
类别
{{ t('pages.historyCat') }}
<select v-model="source">
<option v-for="s in sources" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
<label>
指标
{{ t('pages.historyMetric') }}
<select v-model="metric">
<option v-for="m in metrics" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</label>
<label>
设备
{{ t('pages.historyDevice') }}
<select v-model="deviceId">
<option value="">全部设备</option>
<option value="">{{ t('pages.historyAllDevices') }}</option>
<option v-for="d in devices" :key="d.value" :value="d.value">
{{ d.label }} ({{ d.points }})
</option>
</select>
</label>
<label>
时长
{{ t('pages.historyDuration') }}
<select v-model.number="hours">
<option :value="6">近 6 小时</option>
<option :value="24">近 24 小时</option>
<option :value="72">近 3 天</option>
<option :value="168">近 7 天</option>
<option :value="6">{{ t('pages.historyH6') }}</option>
<option :value="24">{{ t('pages.historyH24') }}</option>
<option :value="72">{{ t('pages.historyH72') }}</option>
<option :value="168">{{ t('pages.historyH168') }}</option>
</select>
</label>
<label>
粒度
{{ t('pages.historyBucket') }}
<select v-model.number="bucketMinutes">
<option :value="5">5 分钟</option>
<option :value="15">15 分钟</option>
<option :value="60">1 小时</option>
<option :value="5">{{ t('pages.historyM5') }}</option>
<option :value="15">{{ t('pages.historyM15') }}</option>
<option :value="60">{{ t('pages.historyM60') }}</option>
</select>
</label>
<button type="button" class="btn" :disabled="loading" @click="loadSeries">刷新</button>
<button type="button" class="btn" :disabled="loading" @click="loadSeries">
{{ t('common.refresh') }}
</button>
</div>
<div v-if="error" class="err">{{ error }}</div>
<div class="chart-box">
<EChart :option="chartOption" :loading="loading" empty-text="暂无该指标历史数据,采集后将自动出现" />
<EChart :option="chartOption" :loading="loading" :empty-text="t('pages.historyEmpty')" />
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>设备</th>
<th>点数</th>
<th>最新</th>
<th>最低</th>
<th>最高</th>
<th>平均</th>
<th>{{ t('common.device') }}</th>
<th>{{ t('pages.historyPoints') }}</th>
<th>{{ t('pages.historyLatest') }}</th>
<th>{{ t('pages.historyMin') }}</th>
<th>{{ t('pages.historyMax') }}</th>
<th>{{ t('pages.historyAvg') }}</th>
</tr>
</thead>
<tbody>
......@@ -210,7 +215,7 @@ onMounted(async () => {
</td>
</tr>
<tr v-if="!series.length">
<td colspan="6" class="muted">无数据</td>
<td colspan="6" class="muted">{{ t('pages.historyNoData') }}</td>
</tr>
</tbody>
</table>
......
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
......@@ -7,6 +8,7 @@ import EChart from '@/components/charts/EChart.vue'
import { useMetricHistory } from '@/composables/useMetricHistory'
import { multiLineOption } from '@/utils/historyChart'
const { t } = useI18n()
const auth = useAuthStore()
const realtime = useRealtimeStore()
const pressureSet = ref(180)
......@@ -27,9 +29,9 @@ const { series: exhaustPressure, loading: loadEx } = useMetricHistory(() => ({
const hvacHistoryOpt = computed(() =>
multiLineOption(
[
...supplyTemp.value.map((s) => ({ ...s, name: '送风温度' })),
...supplyPressure.value.map((s) => ({ ...s, name: '送风压力' })),
...exhaustPressure.value.map((s) => ({ ...s, name: '排风压力' })),
...supplyTemp.value.map((s) => ({ ...s, name: t('pau.supplyTemp') })),
...supplyPressure.value.map((s) => ({ ...s, name: t('pau.supplyPressure') })),
...exhaustPressure.value.map((s) => ({ ...s, name: t('pau.exhaustPressure') })),
],
{ maxSeries: 6 },
),
......@@ -41,9 +43,13 @@ async function applySet() {
await api('/api/realtime/write', {
method: 'POST',
token: auth.token,
body: JSON.stringify({ target: 'PAU201', action: 'set_supply_pressure', value: pressureSet.value }),
body: JSON.stringify({
target: 'PAU201',
action: 'set_supply_pressure',
value: pressureSet.value,
}),
})
alert('送风压力设定已下发(Mock)')
alert(t('pages.hvacApplied'))
}
</script>
......@@ -51,35 +57,75 @@ async function applySet() {
<div class="page-fill">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>空调及排风机组</h3></div>
<div class="badge">{{ realtime.snapshot.hvac.systemRunning ? '运行' : '停机' }}</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.hvacTitle') }}</h3></div>
<div class="badge">
{{ realtime.snapshot.hvac.systemRunning ? t('screen.running') : t('status.stopped') }}
</div>
</div>
<div class="metric-grid" style="grid-template-columns: repeat(3, 1fr)">
<div class="metric"><div><div class="m-label">送风温度</div><div class="m-value">{{ realtime.snapshot.hvac.supplyTemp.toFixed(1) }}</div></div></div>
<div class="metric"><div><div class="m-label">送风湿度</div><div class="m-value">{{ realtime.snapshot.hvac.supplyHumidity }}</div></div></div>
<div class="metric"><div><div class="m-label">送风压力</div><div class="m-value">{{ realtime.snapshot.hvac.supplyPressure }}</div></div></div>
<div class="metric"><div><div class="m-label">风机频率</div><div class="m-value">{{ realtime.snapshot.hvac.supplyFanHz.toFixed(1) }}</div></div></div>
<div class="metric"><div><div class="m-label">排风压力</div><div class="m-value">{{ realtime.snapshot.hvac.exhaustPressure }}</div></div></div>
<div class="metric"><div><div class="m-label">新风温度</div><div class="m-value">{{ realtime.snapshot.hvac.freshAirTemp.toFixed(1) }}</div></div></div>
<div class="metric">
<div>
<div class="m-label">{{ t('pau.supplyTemp') }}</div>
<div class="m-value">{{ realtime.snapshot.hvac.supplyTemp.toFixed(1) }}</div>
</div>
</div>
<div class="metric">
<div>
<div class="m-label">{{ t('pau.supplyHumidity') }}</div>
<div class="m-value">{{ realtime.snapshot.hvac.supplyHumidity }}</div>
</div>
</div>
<div class="metric">
<div>
<div class="m-label">{{ t('pau.supplyPressure') }}</div>
<div class="m-value">{{ realtime.snapshot.hvac.supplyPressure }}</div>
</div>
</div>
<div class="metric">
<div>
<div class="m-label">{{ t('pau.fanHz') }}</div>
<div class="m-value">{{ realtime.snapshot.hvac.supplyFanHz.toFixed(1) }}</div>
</div>
</div>
<div class="metric">
<div>
<div class="m-label">{{ t('pau.exhaustPressure') }}</div>
<div class="m-value">{{ realtime.snapshot.hvac.exhaustPressure }}</div>
</div>
</div>
<div class="metric">
<div>
<div class="m-label">{{ t('pau.freshAirTemp') }}</div>
<div class="m-value">{{ realtime.snapshot.hvac.freshAirTemp.toFixed(1) }}</div>
</div>
</div>
</div>
<div class="hist">
<div class="chart-title">近 24h 历史曲线</div>
<EChart :option="hvacHistoryOpt" :loading="histLoading" empty-text="机组历史积累中" />
<div class="chart-title">{{ t('pages.hvacHistTitle') }}</div>
<EChart
:option="hvacHistoryOpt"
:loading="histLoading"
:empty-text="t('pages.hvacEmpty')"
/>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>参数设定</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">{{ auth.isAdmin ? '管理员可写' : '只读' }}</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.hvacParams') }}</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? t('common.writable') : t('common.readonly') }}
</div>
</div>
<div class="form-grid" style="max-width:520px">
<div class="field">
<label>送风压力设定 (Pa)</label>
<label>{{ t('pages.hvacSetPressure') }}</label>
<input v-model.number="pressureSet" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field" style="justify-content:flex-end">
<label>&nbsp;</label>
<button class="btn" :disabled="!auth.isAdmin" @click="applySet">下发设定</button>
<button class="btn" :disabled="!auth.isAdmin" @click="applySet">
{{ t('pages.hvacApply') }}
</button>
</div>
</div>
</section>
......@@ -95,6 +141,12 @@ async function applySet() {
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.chart-title { font-size: 12px; color: var(--label); margin-bottom: 4px; }
.hist :deep(.echart-wrap) { height: 200px; }
.chart-title {
font-size: 12px;
color: var(--label);
margin-bottom: 4px;
}
.hist :deep(.echart-wrap) {
height: 200px;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import EChart from '@/components/charts/EChart.vue'
import { useMetricHistory } from '@/composables/useMetricHistory'
import { useRealtimeStore } from '@/stores/realtime'
import { multiLineOption } from '@/utils/historyChart'
const { t } = useI18n()
const realtime = useRealtimeStore()
const { series: pressureSeries, loading: pressureLoading } = useMetricHistory(() => ({
source: 'room',
......@@ -23,31 +25,43 @@ const tempOpt = computed(() => multiLineOption(tempSeries.value, { unit: '℃',
<div class="page-fill pressure-page">
<section class="panel" style="flex:1; min-height:0; display:flex; flex-direction:column; gap:10px; overflow:hidden">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>房间压差集中监控</h3></div>
<div class="badge">实时 + 近24h</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.pressureTitle') }}</h3></div>
<div class="badge">{{ t('common.realtime24h') }}</div>
</div>
<div class="charts">
<div class="chart-card">
<div class="chart-title">压差历史 Pa</div>
<EChart :option="pressureOpt" :loading="pressureLoading" empty-text="压差历史积累中" />
<div class="chart-title">{{ t('pages.pressureHist') }}</div>
<EChart
:option="pressureOpt"
:loading="pressureLoading"
:empty-text="t('pages.pressureEmpty')"
/>
</div>
<div class="chart-card">
<div class="chart-title">温度历史 ℃</div>
<EChart :option="tempOpt" :loading="tempLoading" empty-text="温度历史积累中" />
<div class="chart-title">{{ t('pages.tempHist') }}</div>
<EChart :option="tempOpt" :loading="tempLoading" :empty-text="t('pages.tempEmpty')" />
</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>房间</th><th>压差 (Pa)</th><th>温度 (℃)</th><th>湿度 (%)</th><th>告警</th></tr>
<tr>
<th>{{ t('common.room') }}</th>
<th>{{ t('pages.colPressure') }}</th>
<th>{{ t('pages.colTemp') }}</th>
<th>{{ t('pages.colHumidity') }}</th>
<th>{{ t('pages.colAlarm') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in realtime.snapshot.rooms" :key="r.id">
<td>{{ r.name }}</td>
<td :style="{ color: r.alarms.length ? 'var(--red)' : 'var(--green)' }">{{ r.pressure.toFixed(1) }}</td>
<td :style="{ color: r.alarms.length ? 'var(--red)' : 'var(--green)' }">
{{ r.pressure.toFixed(1) }}
</td>
<td>{{ r.temp.toFixed(1) }}</td>
<td>{{ r.humidity }}</td>
<td>{{ r.alarms.join('、') || '正常' }}</td>
<td>{{ r.alarms.join('、') || t('common.normal') }}</td>
</tr>
</tbody>
</table>
......@@ -57,7 +71,10 @@ const tempOpt = computed(() => multiLineOption(tempSeries.value, { unit: '℃',
</template>
<style scoped>
.pressure-page { display: flex; min-height: 0; }
.pressure-page {
display: flex;
min-height: 0;
}
.charts {
display: grid;
grid-template-columns: 1fr 1fr;
......@@ -74,7 +91,17 @@ const tempOpt = computed(() => multiLineOption(tempSeries.value, { unit: '℃',
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.chart-title { font-size: 12px; color: var(--label); margin-bottom: 4px; }
.chart-card :deep(.echart-wrap) { flex: 1; min-height: 180px; }
.table-wrap { max-height: 260px; overflow: auto; }
.chart-title {
font-size: 12px;
color: var(--label);
margin-bottom: 4px;
}
.chart-card :deep(.echart-wrap) {
flex: 1;
min-height: 180px;
}
.table-wrap {
max-height: 260px;
overflow: auto;
}
</style>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const title = computed(() => String(route.meta.title || '功能页'))
const desc = computed(() => String(route.meta.desc || '该模块骨架已就绪,待数据接口与交互深化。'))
const title = computed(() => {
if (route.name === 'roam') return t('pages.roamTitle')
return t('pages.stubTitle')
})
const desc = computed(() => {
if (route.name === 'roam') return t('pages.roamDesc')
return t('pages.stubDesc')
})
</script>
<template>
......
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
......@@ -7,6 +8,7 @@ import EChart from '@/components/charts/EChart.vue'
import { useMetricHistory } from '@/composables/useMetricHistory'
import { multiLineOption } from '@/utils/historyChart'
const { t } = useI18n()
const auth = useAuthStore()
const realtime = useRealtimeStore()
......@@ -21,8 +23,14 @@ const { series: faceVel, loading: loadFace } = useMetricHistory(() => ({
const vavHistoryOpt = computed(() =>
multiLineOption(
[
...airflow.value.map((s) => ({ ...s, name: `${s.name || s.id}·风量` })),
...faceVel.value.map((s) => ({ ...s, name: `${s.name || s.id}·面风速` })),
...airflow.value.map((s) => ({
...s,
name: t('pages.seriesAirflow', { name: s.name || s.id }),
})),
...faceVel.value.map((s) => ({
...s,
name: t('pages.seriesFaceVel', { name: s.name || s.id }),
})),
],
{ maxSeries: 8 },
),
......@@ -36,13 +44,13 @@ async function toggle(id: string, on: boolean) {
token: auth.token,
body: JSON.stringify({ target: id, action: on ? 'start' : 'stop' }),
})
alert(on ? '开机指令已下发(Mock)' : '关机指令已下发(Mock)')
alert(on ? t('pages.vavCmdOn') : t('pages.vavCmdOff'))
}
function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) {
if (v.maxExhaust) return '最大排风'
if (v.minAirflow) return '最小风量'
return '常态'
if (v.maxExhaust) return t('metrics.maxExhaust')
if (v.minAirflow) return t('metrics.minAirflow')
return t('pages.vavModeNormal')
}
</script>
......@@ -50,52 +58,56 @@ function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) {
<div class="page-fill">
<section class="panel vav-page">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通风柜 VAV 集中监控</h3></div>
<div class="badge">{{ auth.isAdmin ? '可控制' : '只读' }} · 近24h曲线</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('pages.vavTitle') }}</h3></div>
<div class="badge">
{{ auth.isAdmin ? t('common.controllable') : t('common.readonly') }} · {{ t('common.last24h') }}
</div>
</div>
<div class="hist">
<EChart :option="vavHistoryOpt" :loading="histLoading" empty-text="VAV 历史积累中" />
<EChart :option="vavHistoryOpt" :loading="histLoading" :empty-text="t('pages.vavEmpty')" />
</div>
<div class="table-wrap">
<table class="table vav-table">
<thead>
<tr>
<th>设备</th>
<th>开停状态</th>
<th>实际面风速</th>
<th>实际门高</th>
<th>实际风量</th>
<th>阀开度</th>
<th>最大排风</th>
<th>最小风量</th>
<th>排风模式</th>
<th>报警代码</th>
<th>操作</th>
<th>{{ t('common.device') }}</th>
<th>{{ t('pages.vavRunState') }}</th>
<th>{{ t('pages.vavFaceVel') }}</th>
<th>{{ t('pages.vavDoorH') }}</th>
<th>{{ t('pages.vavAirflow') }}</th>
<th>{{ t('pages.vavValve') }}</th>
<th>{{ t('pages.vavMaxEx') }}</th>
<th>{{ t('pages.vavMinAf') }}</th>
<th>{{ t('pages.vavExMode') }}</th>
<th>{{ t('pages.vavAlarm') }}</th>
<th>{{ t('common.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in realtime.snapshot.vav" :key="v.id">
<td class="name">{{ v.name }}</td>
<td>{{ v.running ? '运行' : '停止' }}</td>
<td>{{ v.running ? t('screen.running') : t('screen.stopped') }}</td>
<td>{{ v.faceVelocity.toFixed(3) }} m/s</td>
<td>{{ v.sashHeight }} mm</td>
<td>{{ v.airflow }} m³/h</td>
<td>{{ v.valveOpen }} %</td>
<td>{{ v.maxExhaust ? '是' : '否' }}</td>
<td>{{ v.minAirflow ? '是' : '否' }}</td>
<td>{{ v.maxExhaust ? t('common.yes') : t('common.no') }}</td>
<td>{{ v.minAirflow ? t('common.yes') : t('common.no') }}</td>
<td>{{ exhaustText(v) }}</td>
<td>{{ v.alarmCode || '无' }}</td>
<td>{{ v.alarmCode || t('common.none') }}</td>
<td class="ops">
<button class="btn sm" :disabled="!auth.isAdmin" @click="toggle(v.id, true)">开机</button>
<button class="btn ghost sm" :disabled="!auth.isAdmin" @click="toggle(v.id, false)">关机</button>
<button class="btn sm" :disabled="!auth.isAdmin" @click="toggle(v.id, true)">
{{ t('common.start') }}
</button>
<button class="btn ghost sm" :disabled="!auth.isAdmin" @click="toggle(v.id, false)">
{{ t('common.stop') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p class="foot">
点位来源:文库制备1/2通风柜(开停、面风速×0.001、门高、风量、最大/最小风量、报警代码)。阀开度按报价清单监控项展示。
</p>
<p class="foot">{{ t('pages.vavFoot') }}</p>
</section>
</div>
</template>
......@@ -109,7 +121,9 @@ function exhaustText(v: { maxExhaust: boolean; minAirflow: boolean }) {
padding: 8px;
background: rgba(4, 16, 32, 0.35);
}
.hist :deep(.echart-wrap) { height: 180px; }
.hist :deep(.echart-wrap) {
height: 180px;
}
.vav-page {
flex: 1;
min-height: 0;
......
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