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({
......
......@@ -35,6 +35,352 @@ export default {
close: 'Close',
current: 'Current',
unknown: 'Unknown',
camPreview: 'Camera preview',
lightControl: 'Lighting control',
loadFailed: 'Failed to load dashboard data',
modeRtengine: 'RTEngineHD cloud render',
modeUeStream: 'UE streaming mode',
modeLocal: 'Scanned model · free view',
},
weather: {
place: 'Macao',
loading: 'Loading',
empty: 'No data',
},
status: {
online: 'Online',
alarm: 'Alarm',
offline: 'Offline',
unknown: 'Unknown',
overLimit: 'Over limit',
stopped: 'Stopped',
},
deviceCat: {
pau: 'AHU',
vav: 'Fume hoods',
room: 'Rooms',
fridge: 'Cold chain',
meter_power: 'Power meters',
meter_water: 'Water meters',
meter_cooling: 'Cooling meters',
light: 'Lighting',
door: 'Access',
camera: 'Cameras',
nvr: 'NVR',
other: 'Other',
},
pau: {
supplyTemp: 'Supply temp',
supplyHumidity: 'Supply humidity',
supplyPressure: 'Supply pressure',
exhaustPressure: 'Exhaust pressure',
freshAirTemp: 'Fresh-air temp',
systemStart: 'System start',
fanHz: 'Fan frequency',
},
overview: {
kpiOnline: 'Device online %',
kpiCold: 'Cold chain OK',
kpiEnv: 'Rooms OK',
kpiAlarm: 'Active alarms',
gatewayOk: 'up',
gatewayDown: 'down',
gatewayHint: '{online}/{total} · GW {gw}',
allOk: 'All OK',
hasWatch: 'Needs attention',
allNormal: 'All normal',
hasWatchArea: 'Areas to watch',
dutyFollow: 'On duty follow-up',
noOpenAlarm: 'No open alarms',
envTitle: 'Environment',
envQualified: '{ok}/{total} OK',
deviceLedger: 'Device ledger',
envEmpty: 'Room T/H pending',
roomTempEmpty: 'Room temp history accumulating',
coldTitle: 'Cold chain · 24h',
coldEmpty: 'Fridge temp history accumulating',
collecting: 'Collecting',
noFridge: 'No fridge devices',
fridgeWatch: '{n} to watch',
deviceStatus: 'Device status',
alarmCount: '{n} alarms',
runOk: 'Running OK',
noDevice: 'No devices',
noCategory: 'No categories',
onlineStatus: 'Online status',
deviceCats: 'Categories',
systemTitle: 'System status',
unitRunning: 'Unit running',
unitIdle: 'Idle / no signal',
systemEmpty: 'AHU/VAV history accumulating',
bioTitle: 'Biosafety pressure · 24h',
interlockOk: 'Interlock OK',
pressureWatch: 'Pressure watch',
pressureEmpty: 'Pressure history accumulating',
energyTitle: 'Energy & green · 24h',
timeseries: 'Timeseries',
powerTotal: 'Power',
water: 'Water',
cooling: 'Cooling',
energyEmpty: 'Energy history accumulating',
tipTemp: 'Temp {temp}℃ · Humidity {humidity}%',
tipPressure: 'Pressure {pressure} Pa',
seriesSupplyTemp: '{name}·supply T',
seriesAirflow: '{name}·airflow',
seriesPower: 'Power kWh',
seriesWater: 'Water m³',
seriesCooling: 'Cooling kWh',
},
scene: {
noProps: 'No properties',
coords: 'Coordinates',
openVideo: 'Open video',
},
common: {
device: 'Device',
room: 'Room',
action: 'Action',
readonly: 'Read-only',
controllable: 'Controllable',
writable: 'Admin write',
last24h: 'Last 24h curves',
realtime24h: 'Live + last 24h',
refresh: 'Refresh',
yes: 'Yes',
no: 'No',
none: 'None',
normal: 'Normal',
start: 'Start',
stop: 'Stop',
historyAccum: 'History accumulating',
},
pages: {
vavTitle: 'Fume hood VAV monitoring',
vavEmpty: 'VAV history accumulating',
vavRunState: 'Run state',
vavFaceVel: 'Face velocity',
vavDoorH: 'Sash height',
vavAirflow: 'Airflow',
vavValve: 'Valve open',
vavMaxEx: 'Max exhaust',
vavMinAf: 'Min airflow',
vavExMode: 'Exhaust mode',
vavAlarm: 'Alarm code',
vavModeNormal: 'Normal',
vavFoot:
'Points from library-prep fume hoods (run, face velocity×0.001, sash, airflow, max/min, alarm). Valve open per quote list.',
vavCmdOn: 'Start command sent (Mock)',
vavCmdOff: 'Stop command sent (Mock)',
seriesAirflow: '{name}·airflow',
seriesFaceVel: '{name}·face vel.',
pressureTitle: 'Room pressure monitoring',
pressureHist: 'Pressure history Pa',
tempHist: 'Temp history ℃',
pressureEmpty: 'Pressure history accumulating',
tempEmpty: 'Temp history accumulating',
colPressure: 'Pressure (Pa)',
colTemp: 'Temp (℃)',
colHumidity: 'Humidity (%)',
colAlarm: 'Alarm',
hvacTitle: 'HVAC & exhaust',
hvacParams: 'Setpoints',
hvacHistTitle: 'Last 24h history',
hvacEmpty: 'Unit history accumulating',
hvacSetPressure: 'Supply pressure setpoint (Pa)',
hvacApply: 'Apply setpoint',
hvacApplied: 'Supply pressure setpoint sent (Mock)',
accessTitle: 'Access status',
accessToday: 'Passes today {n}',
accessDoor: 'Door',
accessOpenRemote: 'Remote unlock',
accessFoot: 'Protocol: Hikvision HCNetSDK (Windows bridge). Phase 1 Mock; phase 2 bridge.',
accessEvents: 'Access events',
accessTime: 'Time',
accessPerson: 'Person',
accessResult: 'Result',
accessSuccess: 'Success',
accessDenied: 'Denied',
doorOpen: 'Open',
doorClosed: 'Closed',
coldTitle: 'Cold chain · history',
coldBadge: 'Building E · last 24h',
coldTemp: 'Temp ℃',
coldHumidity: 'Humidity %RH',
coldTempEmpty: 'Temp history accumulating',
coldHumidityEmpty: 'Humidity history accumulating',
stubTitle: 'Feature page',
stubDesc: 'Module scaffold ready; deepen once APIs and UX are finalized.',
roamTitle: 'Patrol roam',
roamDesc: 'First-person patrol path synced with points; replace with live UE stream when ready.',
historyTitle: 'History & charts',
historyBadge: 'Pre-aggregated · {summary}',
historySummary: '{n} series · {pts} points · last {hours}h',
historyCat: 'Category',
historyMetric: 'Metric',
historyDevice: 'Device',
historyAllDevices: 'All devices',
historyDuration: 'Duration',
historyBucket: 'Bucket',
historyH6: 'Last 6 hours',
historyH24: 'Last 24 hours',
historyH72: 'Last 3 days',
historyH168: 'Last 7 days',
historyM5: '5 minutes',
historyM15: '15 minutes',
historyM60: '1 hour',
historyEmpty: 'No history for this metric yet; it will appear after collection',
historyPoints: 'Points',
historyLatest: 'Latest',
historyMin: 'Min',
historyMax: 'Max',
historyAvg: 'Avg',
historyNoData: 'No data',
historyCatalogFail: 'Failed to load catalog',
historySeriesFail: 'Failed to load series',
},
video: {
tabDevices: 'Devices',
tabConfig: 'Comm config',
tabPreview: 'Live view',
brand: 'Hikvision',
onlineCount: 'Online {n} / {total}',
addTitle: 'Add Hikvision camera',
admin: 'Admin',
name: 'Name',
namePh: 'e.g. East corridor',
channel: 'NVR channel',
location: 'Location',
locationPh: 'Floor / area',
type: 'Type',
typeNvr: 'Hikvision NVR channel',
typeIpc: 'Hikvision IPC',
ip: 'Camera IP (optional)',
ipPh: 'Required for direct IPC',
remark: 'Remark',
add: 'Add',
listTitle: 'Hikvision devices',
colChannel: 'CH',
colName: 'Name',
colLocation: 'Location',
colType: 'Type',
colIp: 'IP',
colStatus: 'Status',
colEnabled: 'Enabled',
colUpdated: 'Updated',
colAction: 'Actions',
view: 'View',
enable: 'Enable',
disable: 'Disable',
remove: 'Delete',
empty: 'No devices yet; add one or check comm config',
configTitle: 'Hikvision comm config',
vendor: 'Vendor: Hikvision',
editable: 'Editable',
readonly: 'Read-only',
configHint: 'Connect Hikvision NVR / cameras. Prefer HCNetSDK (port 8000); RTSP (554) or ISAPI as fallback. Phase 1 saves config and mock login; phase 2 bridges live via Windows HCNetSDK.',
nvrIp: 'NVR IP',
sdkPort: 'Device port (SDK)',
rtspPort: 'RTSP port',
protocol: 'Protocol',
protocolSdk: 'HCNetSDK (recommended)',
username: 'Username',
password: 'Password',
stream: 'Preview stream',
streamSub: 'Sub stream (recommended)',
streamMain: 'Main stream',
timeout: 'Preview timeout (ms)',
enableComm: 'Enable Hikvision',
enabled: 'Enabled',
disabled: 'Disabled',
save: 'Save',
testing: 'Testing…',
test: 'Test connectivity',
lastUpdate: 'Updated: {time} · {by}',
noChannel: 'No channel selected',
noChannelTitle: 'No channel available',
noChannelHint: 'Enable a Hikvision channel in device management first.',
channelList: 'Channels',
loadFail: 'Load failed',
added: 'Device added',
addFail: 'Add failed',
updateFail: 'Update failed',
confirmDelete: 'Delete device “{name}”?',
deleted: 'Deleted',
deleteFail: 'Delete failed',
saved: 'Comm config saved',
saveFail: 'Save failed',
testOk: 'Connectivity OK',
testFail: 'Test failed',
},
light: {
tabDevices: 'Devices',
tabConfig: 'Comm config',
onCount: 'On {n}',
onlineCount: 'Online {n} / {total}',
addTitle: 'Add lighting circuit',
allOn: 'All on',
allOff: 'All off',
name: 'Name',
namePh: 'e.g. PCR corridor lights',
zone: 'Zone',
zonePh: 'Room / zone',
circuit: 'Circuit No.',
remark: 'Remark',
add: 'Add',
listTitle: 'Lighting devices',
mockBadge: 'Vendor API · Mock',
colCircuit: 'Circuit',
colName: 'Name',
colZone: 'Zone',
colPower: 'Power',
colBrightness: 'Brightness',
colStatus: 'Status',
colEnabled: 'Enabled',
colLast: 'Last control',
colAction: 'Actions',
remoteOff: 'Remote off',
remoteOn: 'Remote on',
sending: 'Sending…',
enable: 'Enable',
disable: 'Disable',
remove: 'Delete',
empty: 'No lighting devices',
foot: 'Vendor API integration for display and remote switching. Phase 1 Mock storage; phase 2 syncs with lighting gateway.',
configTitle: 'Lighting comm config',
editable: 'Editable',
readonly: 'Read-only',
configHint: 'Connect vendor lighting gateway (Modbus / HTTP / TCP). Saved config is used for remote switching.',
host: 'Gateway host',
port: 'Port',
protocol: 'Protocol',
unitId: 'Unit ID',
username: 'Username (optional)',
password: 'Password (optional)',
pollMs: 'Poll interval (ms)',
enableComm: 'Enable comm',
enabled: 'Enabled',
disabled: 'Disabled',
save: 'Save',
testing: 'Testing…',
test: 'Test connectivity',
lastUpdate: 'Updated: {time} · {by}',
loadFail: 'Load failed',
added: 'Device added',
addFail: 'Add failed',
updateFail: 'Update failed',
confirmDelete: 'Delete “{name}”?',
deleted: 'Deleted',
deleteFail: 'Delete failed',
switchFail: 'Switch failed',
confirmAll: 'Remotely {tip} all available circuits?',
batchFail: 'Batch switch failed',
saved: 'Comm config saved',
saveFail: 'Save failed',
testOk: 'Connectivity OK',
testFail: 'Test failed',
fault: 'Fault',
},
metrics: {
temp: 'Temp',
......
......@@ -35,6 +35,352 @@ export default {
close: 'Fechar',
current: 'Atual',
unknown: 'Desconhecido',
camPreview: 'Pré-visualização de câmara',
lightControl: 'Controlo de iluminação',
loadFailed: 'Falha ao carregar o painel',
modeRtengine: 'Renderização RTEngineHD',
modeUeStream: 'Modo de streaming UE',
modeLocal: 'Modelo digitalizado · vista livre',
},
weather: {
place: 'Macau',
loading: 'A obter',
empty: 'Sem dados',
},
status: {
online: 'Online',
alarm: 'Alarme',
offline: 'Offline',
unknown: 'Desconhecido',
overLimit: 'Fora do limite',
stopped: 'Parado',
},
deviceCat: {
pau: 'UTA',
vav: 'Hotte',
room: 'Salas',
fridge: 'Cadeia de frio',
meter_power: 'Contadores elétricos',
meter_water: 'Contadores de água',
meter_cooling: 'Contadores de frio',
light: 'Iluminação',
door: 'Acesso',
camera: 'Câmaras',
nvr: 'NVR',
other: 'Outros',
},
pau: {
supplyTemp: 'Temp. de impulsão',
supplyHumidity: 'Humidade de impulsão',
supplyPressure: 'Pressão de impulsão',
exhaustPressure: 'Pressão de exaustão',
freshAirTemp: 'Temp. do ar novo',
systemStart: 'Arranque do sistema',
fanHz: 'Frequência do ventilador',
},
overview: {
kpiOnline: 'Equip. online %',
kpiCold: 'Cadeia de frio OK',
kpiEnv: 'Salas OK',
kpiAlarm: 'Alarmes ativos',
gatewayOk: 'ok',
gatewayDown: 'falha',
gatewayHint: '{online}/{total} · GW {gw}',
allOk: 'Tudo OK',
hasWatch: 'Em atenção',
allNormal: 'Tudo normal',
hasWatchArea: 'Áreas a vigiar',
dutyFollow: 'Em acompanhamento',
noOpenAlarm: 'Sem alarmes abertos',
envTitle: 'Ambiente',
envQualified: '{ok}/{total} OK',
deviceLedger: 'Inventário',
envEmpty: 'T/H das salas pendente',
roomTempEmpty: 'Histórico de temp. a acumular',
coldTitle: 'Cadeia de frio · 24h',
coldEmpty: 'Histórico de frigoríficos a acumular',
collecting: 'A recolher',
noFridge: 'Sem frigoríficos',
fridgeWatch: '{n} a vigiar',
deviceStatus: 'Estado dos equipamentos',
alarmCount: '{n} alarmes',
runOk: 'A funcionar',
noDevice: 'Sem equipamentos',
noCategory: 'Sem categorias',
onlineStatus: 'Estado online',
deviceCats: 'Categorias',
systemTitle: 'Estado do sistema',
unitRunning: 'Unidade a funcionar',
unitIdle: 'Parado / sem sinal',
systemEmpty: 'Histórico UTA/VAV a acumular',
bioTitle: 'Pressão de biossegurança · 24h',
interlockOk: 'Interbloqueio OK',
pressureWatch: 'Pressão a vigiar',
pressureEmpty: 'Histórico de pressão a acumular',
energyTitle: 'Energia e sustentabilidade · 24h',
timeseries: 'Séries',
powerTotal: 'Eletricidade',
water: 'Água',
cooling: 'Frio',
energyEmpty: 'Histórico de energia a acumular',
tipTemp: 'Temp. {temp}℃ · Humidade {humidity}%',
tipPressure: 'Pressão {pressure} Pa',
seriesSupplyTemp: '{name}·T impulsão',
seriesAirflow: '{name}·caudal',
seriesPower: 'Eletricidade kWh',
seriesWater: 'Água m³',
seriesCooling: 'Frio kWh',
},
scene: {
noProps: 'Sem propriedades',
coords: 'Coordenadas',
openVideo: 'Abrir vídeo',
},
common: {
device: 'Equipamento',
room: 'Sala',
action: 'Ação',
readonly: 'Só leitura',
controllable: 'Controlável',
writable: 'Admin pode escrever',
last24h: 'Curvas 24h',
realtime24h: 'Tempo real + 24h',
refresh: 'Atualizar',
yes: 'Sim',
no: 'Não',
none: 'Nenhum',
normal: 'Normal',
start: 'Ligar',
stop: 'Desligar',
historyAccum: 'Histórico a acumular',
},
pages: {
vavTitle: 'Monitorização VAV de hotte',
vavEmpty: 'Histórico VAV a acumular',
vavRunState: 'Estado',
vavFaceVel: 'Vel. frontal',
vavDoorH: 'Altura da janela',
vavAirflow: 'Caudal',
vavValve: 'Abertura da válvula',
vavMaxEx: 'Exaustão máx.',
vavMinAf: 'Caudal mín.',
vavExMode: 'Modo de exaustão',
vavAlarm: 'Código de alarme',
vavModeNormal: 'Normal',
vavFoot:
'Pontos das hottes de preparação de biblioteca (arranque, vel. frontal×0.001, janela, caudal, máx/mín, alarme).',
vavCmdOn: 'Comando de arranque enviado (Mock)',
vavCmdOff: 'Comando de paragem enviado (Mock)',
seriesAirflow: '{name}·caudal',
seriesFaceVel: '{name}·vel. frontal',
pressureTitle: 'Monitorização de pressão das salas',
pressureHist: 'Histórico de pressão Pa',
tempHist: 'Histórico de temp. ℃',
pressureEmpty: 'Histórico de pressão a acumular',
tempEmpty: 'Histórico de temp. a acumular',
colPressure: 'Pressão (Pa)',
colTemp: 'Temp. (℃)',
colHumidity: 'Humidade (%)',
colAlarm: 'Alarme',
hvacTitle: 'AVAC e exaustão',
hvacParams: 'Parâmetros',
hvacHistTitle: 'Histórico 24h',
hvacEmpty: 'Histórico da unidade a acumular',
hvacSetPressure: 'Consigne de pressão de impulsão (Pa)',
hvacApply: 'Aplicar',
hvacApplied: 'Consigne de pressão enviada (Mock)',
accessTitle: 'Estado de acesso',
accessToday: 'Passagens hoje {n}',
accessDoor: 'Porta',
accessOpenRemote: 'Abrir remotamente',
accessFoot: 'Protocolo: Hikvision HCNetSDK (ponte Windows). Fase 1 Mock; fase 2 ponte.',
accessEvents: 'Registos de passagem',
accessTime: 'Hora',
accessPerson: 'Pessoa',
accessResult: 'Resultado',
accessSuccess: 'Sucesso',
accessDenied: 'Negado',
doorOpen: 'Aberta',
doorClosed: 'Fechada',
coldTitle: 'Cadeia de frio · histórico',
coldBadge: 'Edifício E · 24h',
coldTemp: 'Temp. ℃',
coldHumidity: 'Humidade %RH',
coldTempEmpty: 'Histórico de temp. a acumular',
coldHumidityEmpty: 'Histórico de humidade a acumular',
stubTitle: 'Página de funcionalidade',
stubDesc: 'Esqueleto do módulo pronto; aprofundar quando as APIs e a UX estiverem definidas.',
roamTitle: 'Ronda',
roamDesc: 'Percurso em primeira pessoa sincronizado com pontos; substituir por stream UE quando pronto.',
historyTitle: 'Histórico e gráficos',
historyBadge: 'Pré-agregado · {summary}',
historySummary: '{n} séries · {pts} pontos · últimas {hours}h',
historyCat: 'Categoria',
historyMetric: 'Métrica',
historyDevice: 'Equipamento',
historyAllDevices: 'Todos os equipamentos',
historyDuration: 'Duração',
historyBucket: 'Granularidade',
historyH6: 'Últimas 6 horas',
historyH24: 'Últimas 24 horas',
historyH72: 'Últimos 3 dias',
historyH168: 'Últimos 7 dias',
historyM5: '5 minutos',
historyM15: '15 minutos',
historyM60: '1 hora',
historyEmpty: 'Ainda sem histórico desta métrica; aparecerá após a recolha',
historyPoints: 'Pontos',
historyLatest: 'Último',
historyMin: 'Mín.',
historyMax: 'Máx.',
historyAvg: 'Média',
historyNoData: 'Sem dados',
historyCatalogFail: 'Falha ao carregar o catálogo',
historySeriesFail: 'Falha ao carregar as séries',
},
video: {
tabDevices: 'Dispositivos',
tabConfig: 'Comunicações',
tabPreview: 'Visualização',
brand: 'Hikvision',
onlineCount: 'Online {n} / {total}',
addTitle: 'Adicionar câmara Hikvision',
admin: 'Admin',
name: 'Nome',
namePh: 'ex.: Corredor este',
channel: 'Canal NVR',
location: 'Localização',
locationPh: 'Piso / área',
type: 'Tipo',
typeNvr: 'Canal NVR Hikvision',
typeIpc: 'IPC Hikvision',
ip: 'IP da câmara (opcional)',
ipPh: 'Necessário para IPC direto',
remark: 'Nota',
add: 'Adicionar',
listTitle: 'Dispositivos Hikvision',
colChannel: 'Canal',
colName: 'Nome',
colLocation: 'Local',
colType: 'Tipo',
colIp: 'IP',
colStatus: 'Estado',
colEnabled: 'Ativo',
colUpdated: 'Atualizado',
colAction: 'Ações',
view: 'Ver',
enable: 'Ativar',
disable: 'Desativar',
remove: 'Eliminar',
empty: 'Sem dispositivos; adicione ou verifique a configuração',
configTitle: 'Configuração Hikvision',
vendor: 'Fabricante: Hikvision',
editable: 'Editável',
readonly: 'Só leitura',
configHint: 'Ligar NVR / câmaras Hikvision. Preferir HCNetSDK (porta 8000); RTSP (554) ou ISAPI como alternativa. Fase 1 grava config e teste Mock; fase 2 ponte Windows HCNetSDK.',
nvrIp: 'IP do NVR',
sdkPort: 'Porta do dispositivo (SDK)',
rtspPort: 'Porta RTSP',
protocol: 'Protocolo',
protocolSdk: 'HCNetSDK (recomendado)',
username: 'Utilizador',
password: 'Palavra-passe',
stream: 'Fluxo de pré-visualização',
streamSub: 'Subfluxo (recomendado)',
streamMain: 'Fluxo principal',
timeout: 'Timeout (ms)',
enableComm: 'Ativar Hikvision',
enabled: 'Ativado',
disabled: 'Desativado',
save: 'Guardar',
testing: 'A testar…',
test: 'Testar ligação',
lastUpdate: 'Atualizado: {time} · {by}',
noChannel: 'Nenhum canal selecionado',
noChannelTitle: 'Sem canais',
noChannelHint: 'Ative primeiro um canal Hikvision na gestão de dispositivos.',
channelList: 'Canais',
loadFail: 'Falha ao carregar',
added: 'Dispositivo adicionado',
addFail: 'Falha ao adicionar',
updateFail: 'Falha ao atualizar',
confirmDelete: 'Eliminar dispositivo “{name}”?',
deleted: 'Eliminado',
deleteFail: 'Falha ao eliminar',
saved: 'Configuração guardada',
saveFail: 'Falha ao guardar',
testOk: 'Ligação OK',
testFail: 'Falha no teste',
},
light: {
tabDevices: 'Dispositivos',
tabConfig: 'Comunicações',
onCount: 'Ligados {n}',
onlineCount: 'Online {n} / {total}',
addTitle: 'Adicionar circuito de iluminação',
allOn: 'Ligar todos',
allOff: 'Desligar todos',
name: 'Nome',
namePh: 'ex.: Iluminação corredor PCR',
zone: 'Zona',
zonePh: 'Sala / zona',
circuit: 'N.º do circuito',
remark: 'Nota',
add: 'Adicionar',
listTitle: 'Dispositivos de iluminação',
mockBadge: 'API fabricante · Mock',
colCircuit: 'Circuito',
colName: 'Nome',
colZone: 'Zona',
colPower: 'Interruptor',
colBrightness: 'Brilho',
colStatus: 'Estado',
colEnabled: 'Ativo',
colLast: 'Último controlo',
colAction: 'Ações',
remoteOff: 'Desligar remoto',
remoteOn: 'Ligar remoto',
sending: 'A enviar…',
enable: 'Ativar',
disable: 'Desativar',
remove: 'Eliminar',
empty: 'Sem dispositivos de iluminação',
foot: 'Integração da API do fabricante para visualização e controlo remoto. Fase 1 Mock; fase 2 sincroniza com o gateway.',
configTitle: 'Configuração de iluminação',
editable: 'Editável',
readonly: 'Só leitura',
configHint: 'Ligar gateway de iluminação (Modbus / HTTP / TCP). A configuração guardada serve para comandos remotos.',
host: 'Endereço do gateway',
port: 'Porta',
protocol: 'Protocolo',
unitId: 'Unit ID',
username: 'Utilizador (opcional)',
password: 'Palavra-passe (opcional)',
pollMs: 'Polling (ms)',
enableComm: 'Ativar comunicação',
enabled: 'Ativado',
disabled: 'Desativado',
save: 'Guardar',
testing: 'A testar…',
test: 'Testar ligação',
lastUpdate: 'Atualizado: {time} · {by}',
loadFail: 'Falha ao carregar',
added: 'Dispositivo adicionado',
addFail: 'Falha ao adicionar',
updateFail: 'Falha ao atualizar',
confirmDelete: 'Eliminar “{name}”?',
deleted: 'Eliminado',
deleteFail: 'Falha ao eliminar',
switchFail: 'Falha no interruptor',
confirmAll: 'Confirmar {tip} remoto de todos os circuitos disponíveis?',
batchFail: 'Falha no comando em lote',
saved: 'Configuração guardada',
saveFail: 'Falha ao guardar',
testOk: 'Ligação OK',
testFail: 'Falha no teste',
fault: 'Avaria',
},
metrics: {
temp: 'Temp.',
......
......@@ -35,6 +35,352 @@ export default {
close: '关闭',
current: '当前',
unknown: '未知',
camPreview: '监控预览',
lightControl: '照明控制',
loadFailed: '大屏数据加载失败',
modeRtengine: 'RTEngineHD 云渲染',
modeUeStream: 'UE 推流模式',
modeLocal: '实景扫描模型 · 自由视角',
},
weather: {
place: '澳门',
loading: '获取中',
empty: '暂无数据',
},
status: {
online: '在线',
alarm: '告警',
offline: '离线',
unknown: '未知',
overLimit: '超限',
stopped: '停机',
},
deviceCat: {
pau: '空调机组',
vav: '通风柜',
room: '房间环境',
fridge: '冷链冰箱',
meter_power: '电表',
meter_water: '水表',
meter_cooling: '冷量表',
light: '照明',
door: '门禁',
camera: '摄像',
nvr: '录像',
other: '其它',
},
pau: {
supplyTemp: '送风温度',
supplyHumidity: '送风湿度',
supplyPressure: '送风压力',
exhaustPressure: '排风压力',
freshAirTemp: '新风温度',
systemStart: '系统启动',
fanHz: '风机频率',
},
overview: {
kpiOnline: '设备在线率 %',
kpiCold: '冷链正常',
kpiEnv: '环境合格区',
kpiAlarm: '当前告警',
gatewayOk: '通',
gatewayDown: '断',
gatewayHint: '{online}/{total} · 网关{gw}',
allOk: '全部达标',
hasWatch: '有关注项',
allNormal: '全部正常',
hasWatchArea: '有关注区',
dutyFollow: '值班跟进中',
noOpenAlarm: '无未闭环',
envTitle: '环境总览',
envQualified: '{ok}/{total} 合格',
deviceLedger: '设备台账',
envEmpty: '房间温湿度待采集',
roomTempEmpty: '房间温度历史积累中',
coldTitle: '冷链监测 · 24h',
coldEmpty: '冰箱温度历史积累中',
collecting: '采集中',
noFridge: '无冰箱设备',
fridgeWatch: '{n}台关注',
deviceStatus: '设备状态',
alarmCount: '{n}项告警',
runOk: '运行正常',
noDevice: '无设备',
noCategory: '无分类',
onlineStatus: '在线状态',
deviceCats: '设备分类',
systemTitle: '系统运行状态',
unitRunning: '机组运行',
unitIdle: '未启动/无信号',
systemEmpty: '机组/VAV 历史积累中',
bioTitle: '生物安全压差 · 24h',
interlockOk: '联锁正常',
pressureWatch: '压差关注',
pressureEmpty: '压差历史积累中',
energyTitle: '能耗与绿色 · 24h',
timeseries: '时序',
powerTotal: '总用电',
water: '用水',
cooling: '冷量',
energyEmpty: '能耗历史积累中',
tipTemp: '温度 {temp}℃ · 湿度 {humidity}%',
tipPressure: '压差 {pressure} Pa',
seriesSupplyTemp: '{name}·送风温',
seriesAirflow: '{name}·风量',
seriesPower: '用电 kWh',
seriesWater: '用水 m³',
seriesCooling: '冷量 kWh',
},
scene: {
noProps: '暂无属性',
coords: '坐标',
openVideo: '打开视频',
},
common: {
device: '设备',
room: '房间',
action: '操作',
readonly: '只读',
controllable: '可控制',
writable: '管理员可写',
last24h: '近24h曲线',
realtime24h: '实时 + 近24h',
refresh: '刷新',
yes: '是',
no: '否',
none: '无',
normal: '正常',
start: '开机',
stop: '关机',
historyAccum: '历史积累中',
},
pages: {
vavTitle: '通风柜 VAV 集中监控',
vavEmpty: 'VAV 历史积累中',
vavRunState: '开停状态',
vavFaceVel: '实际面风速',
vavDoorH: '实际门高',
vavAirflow: '实际风量',
vavValve: '阀开度',
vavMaxEx: '最大排风',
vavMinAf: '最小风量',
vavExMode: '排风模式',
vavAlarm: '报警代码',
vavModeNormal: '常态',
vavFoot:
'点位来源:文库制备1/2通风柜(开停、面风速×0.001、门高、风量、最大/最小风量、报警代码)。阀开度按报价清单监控项展示。',
vavCmdOn: '开机指令已下发(Mock)',
vavCmdOff: '关机指令已下发(Mock)',
seriesAirflow: '{name}·风量',
seriesFaceVel: '{name}·面风速',
pressureTitle: '房间压差集中监控',
pressureHist: '压差历史 Pa',
tempHist: '温度历史 ℃',
pressureEmpty: '压差历史积累中',
tempEmpty: '温度历史积累中',
colPressure: '压差 (Pa)',
colTemp: '温度 (℃)',
colHumidity: '湿度 (%)',
colAlarm: '告警',
hvacTitle: '空调及排风机组',
hvacParams: '参数设定',
hvacHistTitle: '近 24h 历史曲线',
hvacEmpty: '机组历史积累中',
hvacSetPressure: '送风压力设定 (Pa)',
hvacApply: '下发设定',
hvacApplied: '送风压力设定已下发(Mock)',
accessTitle: '门禁状态',
accessToday: '今日通行 {n}',
accessDoor: '门点',
accessOpenRemote: '远程开门',
accessFoot: '协议:海康 HCNetSDK(Windows 桥接)。一期为 Mock,二期接入桥接服务。',
accessEvents: '通行记录',
accessTime: '时间',
accessPerson: '人员',
accessResult: '结果',
accessSuccess: '成功',
accessDenied: '拒绝',
doorOpen: '开启',
doorClosed: '关闭',
coldTitle: '冷链监测 · 历史曲线',
coldBadge: 'E栋 · 近 24h',
coldTemp: '温度 ℃',
coldHumidity: '湿度 %RH',
coldTempEmpty: '温度历史积累中',
coldHumidityEmpty: '湿度历史积累中',
stubTitle: '功能页',
stubDesc: '该模块骨架已就绪,待数据接口与交互深化。',
roamTitle: '漫游巡检',
roamDesc: '第一人称巡检路径与点位数据同步,UE 推流到位后可替换为真实漫游。',
historyTitle: '历史数据与曲线',
historyBadge: '时序预聚合 · {summary}',
historySummary: '{n} 条序列 · {pts} 个聚合点 · 近 {hours}h',
historyCat: '类别',
historyMetric: '指标',
historyDevice: '设备',
historyAllDevices: '全部设备',
historyDuration: '时长',
historyBucket: '粒度',
historyH6: '近 6 小时',
historyH24: '近 24 小时',
historyH72: '近 3 天',
historyH168: '近 7 天',
historyM5: '5 分钟',
historyM15: '15 分钟',
historyM60: '1 小时',
historyEmpty: '暂无该指标历史数据,采集后将自动出现',
historyPoints: '点数',
historyLatest: '最新',
historyMin: '最低',
historyMax: '最高',
historyAvg: '平均',
historyNoData: '无数据',
historyCatalogFail: '目录加载失败',
historySeriesFail: '曲线加载失败',
},
video: {
tabDevices: '设备管理',
tabConfig: '通信配置',
tabPreview: '查看监控',
brand: '海康威视',
onlineCount: '在线 {n} / {total}',
addTitle: '添加海康监控设备',
admin: '管理员',
name: '名称',
namePh: '如:走廊东',
channel: 'NVR 通道号',
location: '安装位置',
locationPh: '楼层/区域',
type: '类型',
typeNvr: '海康 NVR 通道',
typeIpc: '海康网络摄像机',
ip: '摄像机 IP(可选)',
ipPh: 'IPC 直连时填写',
remark: '备注',
add: '添加',
listTitle: '海康监控设备列表',
colChannel: '通道',
colName: '名称',
colLocation: '位置',
colType: '类型',
colIp: 'IP',
colStatus: '状态',
colEnabled: '启用',
colUpdated: '更新时间',
colAction: '操作',
view: '查看',
enable: '启用',
disable: '禁用',
remove: '删除',
empty: '暂无设备,请先添加或检查通信配置',
configTitle: '海康通信配置',
vendor: '厂商:海康威视',
editable: '可编辑',
readonly: '只读',
configHint: '对接海康威视 NVR / 摄像机。推荐 HCNetSDK(设备端口 8000)预览;RTSP(554)或 ISAPI 作备选。一期保存配置并 Mock 登录测试;二期经 Windows HCNetSDK 桥接拉取实况(与门禁桥接同类)。',
nvrIp: '海康 NVR IP',
sdkPort: '设备端口(SDK)',
rtspPort: 'RTSP 端口',
protocol: '接入方式',
protocolSdk: 'HCNetSDK(推荐)',
username: '设备用户名',
password: '设备密码',
stream: '预览码流',
streamSub: '子码流(推荐)',
streamMain: '主码流',
timeout: '预览超时 (ms)',
enableComm: '启用海康通信',
enabled: '已启用',
disabled: '已关闭',
save: '保存配置',
testing: '测试中…',
test: '测试连通性',
lastUpdate: '最近更新:{time} · {by}',
noChannel: '未选择通道',
noChannelTitle: '无可用通道',
noChannelHint: '请先在设备管理中启用海康监控通道。',
channelList: '通道列表',
loadFail: '加载失败',
added: '设备已添加',
addFail: '添加失败',
updateFail: '更新失败',
confirmDelete: '确认删除设备「{name}」?',
deleted: '已删除',
deleteFail: '删除失败',
saved: '通信配置已保存',
saveFail: '保存失败',
testOk: '连通性测试通过',
testFail: '测试失败',
},
light: {
tabDevices: '设备管理',
tabConfig: '通信配置',
onCount: '开启 {n}',
onlineCount: '在线 {n} / {total}',
addTitle: '添加照明回路',
allOn: '全部开启',
allOff: '全部关闭',
name: '名称',
namePh: '如:PCR走廊照明',
zone: '区域',
zonePh: '房间/分区',
circuit: '回路号',
remark: '备注',
add: '添加',
listTitle: '照明设备列表',
mockBadge: '厂家接口 · Mock',
colCircuit: '回路',
colName: '名称',
colZone: '区域',
colPower: '开关',
colBrightness: '亮度',
colStatus: '状态',
colEnabled: '启用',
colLast: '最近控制',
colAction: '操作',
remoteOff: '远程关灯',
remoteOn: '远程开灯',
sending: '下发中…',
enable: '启用',
disable: '禁用',
remove: '删除',
empty: '暂无照明设备',
foot: '厂家提供数据调用接口,本页负责显示集成与远程开关。一期为 Mock 落库;二期对接照明网关后实时同步。',
configTitle: '照明通信配置',
editable: '可编辑',
readonly: '只读',
configHint: '对接厂家智能照明网关(Modbus / HTTP / TCP)。配置保存后可用于远程开关下发。',
host: '网关地址',
port: '端口',
protocol: '协议',
unitId: '从站地址 (Unit ID)',
username: '用户名(可选)',
password: '密码(可选)',
pollMs: '状态轮询 (ms)',
enableComm: '启用通信',
enabled: '已启用',
disabled: '已关闭',
save: '保存配置',
testing: '测试中…',
test: '测试连通性',
lastUpdate: '最近更新:{time} · {by}',
loadFail: '加载失败',
added: '设备已添加',
addFail: '添加失败',
updateFail: '更新失败',
confirmDelete: '确认删除「{name}」?',
deleted: '已删除',
deleteFail: '删除失败',
switchFail: '开关失败',
confirmAll: '确认远程{tip}所有可用回路?',
batchFail: '批量开关失败',
saved: '通信配置已保存',
saveFail: '保存失败',
testOk: '连通性测试通过',
testFail: '测试失败',
fault: '故障',
},
metrics: {
temp: '温度',
......
......@@ -35,6 +35,352 @@ export default {
close: '關閉',
current: '當前',
unknown: '未知',
camPreview: '監控預覽',
lightControl: '照明控制',
loadFailed: '大屏資料載入失敗',
modeRtengine: 'RTEngineHD 雲渲染',
modeUeStream: 'UE 推流模式',
modeLocal: '實景掃描模型 · 自由視角',
},
weather: {
place: '澳門',
loading: '取得中',
empty: '暫無資料',
},
status: {
online: '線上',
alarm: '告警',
offline: '離線',
unknown: '未知',
overLimit: '超限',
stopped: '停機',
},
deviceCat: {
pau: '空調機組',
vav: '通風櫃',
room: '房間環境',
fridge: '冷鏈冰箱',
meter_power: '電表',
meter_water: '水表',
meter_cooling: '冷量表',
light: '照明',
door: '門禁',
camera: '攝像',
nvr: '錄影',
other: '其它',
},
pau: {
supplyTemp: '送風溫度',
supplyHumidity: '送風濕度',
supplyPressure: '送風壓力',
exhaustPressure: '排風壓力',
freshAirTemp: '新風溫度',
systemStart: '系統啟動',
fanHz: '風機頻率',
},
overview: {
kpiOnline: '設備線上率 %',
kpiCold: '冷鏈正常',
kpiEnv: '環境合格區',
kpiAlarm: '當前告警',
gatewayOk: '通',
gatewayDown: '斷',
gatewayHint: '{online}/{total} · 閘道{gw}',
allOk: '全部達標',
hasWatch: '有關注項',
allNormal: '全部正常',
hasWatchArea: '有關注區',
dutyFollow: '值班跟進中',
noOpenAlarm: '無未閉環',
envTitle: '環境總覽',
envQualified: '{ok}/{total} 合格',
deviceLedger: '設備台帳',
envEmpty: '房間溫濕度待採集',
roomTempEmpty: '房間溫度歷史累積中',
coldTitle: '冷鏈監測 · 24h',
coldEmpty: '冰箱溫度歷史累積中',
collecting: '採集中',
noFridge: '無冰箱設備',
fridgeWatch: '{n}台關注',
deviceStatus: '設備狀態',
alarmCount: '{n}項告警',
runOk: '運行正常',
noDevice: '無設備',
noCategory: '無分類',
onlineStatus: '線上狀態',
deviceCats: '設備分類',
systemTitle: '系統運行狀態',
unitRunning: '機組運行',
unitIdle: '未啟動/無信號',
systemEmpty: '機組/VAV 歷史累積中',
bioTitle: '生物安全壓差 · 24h',
interlockOk: '聯鎖正常',
pressureWatch: '壓差關注',
pressureEmpty: '壓差歷史累積中',
energyTitle: '能耗與綠色 · 24h',
timeseries: '時序',
powerTotal: '總用電',
water: '用水',
cooling: '冷量',
energyEmpty: '能耗歷史累積中',
tipTemp: '溫度 {temp}℃ · 濕度 {humidity}%',
tipPressure: '壓差 {pressure} Pa',
seriesSupplyTemp: '{name}·送風溫',
seriesAirflow: '{name}·風量',
seriesPower: '用電 kWh',
seriesWater: '用水 m³',
seriesCooling: '冷量 kWh',
},
scene: {
noProps: '暫無屬性',
coords: '座標',
openVideo: '開啟影片',
},
common: {
device: '設備',
room: '房間',
action: '操作',
readonly: '唯讀',
controllable: '可控制',
writable: '管理員可寫',
last24h: '近24h曲線',
realtime24h: '即時 + 近24h',
refresh: '重新整理',
yes: '是',
no: '否',
none: '無',
normal: '正常',
start: '開機',
stop: '關機',
historyAccum: '歷史累積中',
},
pages: {
vavTitle: '通風櫃 VAV 集中監控',
vavEmpty: 'VAV 歷史累積中',
vavRunState: '開停狀態',
vavFaceVel: '實際面風速',
vavDoorH: '實際門高',
vavAirflow: '實際風量',
vavValve: '閥開度',
vavMaxEx: '最大排風',
vavMinAf: '最小風量',
vavExMode: '排風模式',
vavAlarm: '報警代碼',
vavModeNormal: '常態',
vavFoot:
'點位來源:文庫製備1/2通風櫃(開停、面風速×0.001、門高、風量、最大/最小風量、報警代碼)。閥開度按報價清單監控項展示。',
vavCmdOn: '開機指令已下發(Mock)',
vavCmdOff: '關機指令已下發(Mock)',
seriesAirflow: '{name}·風量',
seriesFaceVel: '{name}·面風速',
pressureTitle: '房間壓差集中監控',
pressureHist: '壓差歷史 Pa',
tempHist: '溫度歷史 ℃',
pressureEmpty: '壓差歷史累積中',
tempEmpty: '溫度歷史累積中',
colPressure: '壓差 (Pa)',
colTemp: '溫度 (℃)',
colHumidity: '濕度 (%)',
colAlarm: '告警',
hvacTitle: '空調及排風機組',
hvacParams: '參數設定',
hvacHistTitle: '近 24h 歷史曲線',
hvacEmpty: '機組歷史累積中',
hvacSetPressure: '送風壓力設定 (Pa)',
hvacApply: '下發設定',
hvacApplied: '送風壓力設定已下發(Mock)',
accessTitle: '門禁狀態',
accessToday: '今日通行 {n}',
accessDoor: '門點',
accessOpenRemote: '遠端開門',
accessFoot: '協議:海康 HCNetSDK(Windows 橋接)。一期為 Mock,二期接入橋接服務。',
accessEvents: '通行記錄',
accessTime: '時間',
accessPerson: '人員',
accessResult: '結果',
accessSuccess: '成功',
accessDenied: '拒絕',
doorOpen: '開啟',
doorClosed: '關閉',
coldTitle: '冷鏈監測 · 歷史曲線',
coldBadge: 'E棟 · 近 24h',
coldTemp: '溫度 ℃',
coldHumidity: '濕度 %RH',
coldTempEmpty: '溫度歷史累積中',
coldHumidityEmpty: '濕度歷史累積中',
stubTitle: '功能頁',
stubDesc: '該模組骨架已就緒,待資料介面與互動深化。',
roamTitle: '漫遊巡檢',
roamDesc: '第一人稱巡檢路徑與點位資料同步,UE 推流到位後可替換為真實漫遊。',
historyTitle: '歷史資料與曲線',
historyBadge: '時序預聚合 · {summary}',
historySummary: '{n} 條序列 · {pts} 個聚合點 · 近 {hours}h',
historyCat: '類別',
historyMetric: '指標',
historyDevice: '設備',
historyAllDevices: '全部設備',
historyDuration: '時長',
historyBucket: '粒度',
historyH6: '近 6 小時',
historyH24: '近 24 小時',
historyH72: '近 3 天',
historyH168: '近 7 天',
historyM5: '5 分鐘',
historyM15: '15 分鐘',
historyM60: '1 小時',
historyEmpty: '暫無該指標歷史資料,採集後將自動出現',
historyPoints: '點數',
historyLatest: '最新',
historyMin: '最低',
historyMax: '最高',
historyAvg: '平均',
historyNoData: '無資料',
historyCatalogFail: '目錄載入失敗',
historySeriesFail: '曲線載入失敗',
},
video: {
tabDevices: '設備管理',
tabConfig: '通訊設定',
tabPreview: '檢視監控',
brand: '海康威視',
onlineCount: '線上 {n} / {total}',
addTitle: '新增海康監控設備',
admin: '管理員',
name: '名稱',
namePh: '如:走廊東',
channel: 'NVR 通道號',
location: '安裝位置',
locationPh: '樓層/區域',
type: '類型',
typeNvr: '海康 NVR 通道',
typeIpc: '海康網路攝像機',
ip: '攝像機 IP(可選)',
ipPh: 'IPC 直連時填寫',
remark: '備註',
add: '新增',
listTitle: '海康監控設備列表',
colChannel: '通道',
colName: '名稱',
colLocation: '位置',
colType: '類型',
colIp: 'IP',
colStatus: '狀態',
colEnabled: '啟用',
colUpdated: '更新時間',
colAction: '操作',
view: '檢視',
enable: '啟用',
disable: '停用',
remove: '刪除',
empty: '暫無設備,請先新增或檢查通訊設定',
configTitle: '海康通訊設定',
vendor: '廠商:海康威視',
editable: '可編輯',
readonly: '唯讀',
configHint: '對接海康威視 NVR / 攝像機。推薦 HCNetSDK(設備埠 8000)預覽;RTSP(554)或 ISAPI 作備選。一期儲存設定並 Mock 登入測試;二期經 Windows HCNetSDK 橋接拉取實況(與門禁橋接同類)。',
nvrIp: '海康 NVR IP',
sdkPort: '設備埠(SDK)',
rtspPort: 'RTSP 埠',
protocol: '接入方式',
protocolSdk: 'HCNetSDK(推薦)',
username: '設備使用者名稱',
password: '設備密碼',
stream: '預覽碼流',
streamSub: '子碼流(推薦)',
streamMain: '主碼流',
timeout: '預覽逾時 (ms)',
enableComm: '啟用海康通訊',
enabled: '已啟用',
disabled: '已關閉',
save: '儲存設定',
testing: '測試中…',
test: '測試連通性',
lastUpdate: '最近更新:{time} · {by}',
noChannel: '未選擇通道',
noChannelTitle: '無可用通道',
noChannelHint: '請先在設備管理中啟用海康監控通道。',
channelList: '通道列表',
loadFail: '載入失敗',
added: '設備已新增',
addFail: '新增失敗',
updateFail: '更新失敗',
confirmDelete: '確認刪除設備「{name}」?',
deleted: '已刪除',
deleteFail: '刪除失敗',
saved: '通訊設定已儲存',
saveFail: '儲存失敗',
testOk: '連通性測試通過',
testFail: '測試失敗',
},
light: {
tabDevices: '設備管理',
tabConfig: '通訊設定',
onCount: '開啟 {n}',
onlineCount: '線上 {n} / {total}',
addTitle: '新增照明迴路',
allOn: '全部開啟',
allOff: '全部關閉',
name: '名稱',
namePh: '如:PCR走廊照明',
zone: '區域',
zonePh: '房間/分區',
circuit: '迴路號',
remark: '備註',
add: '新增',
listTitle: '照明設備列表',
mockBadge: '廠家介面 · Mock',
colCircuit: '迴路',
colName: '名稱',
colZone: '區域',
colPower: '開關',
colBrightness: '亮度',
colStatus: '狀態',
colEnabled: '啟用',
colLast: '最近控制',
colAction: '操作',
remoteOff: '遠端關燈',
remoteOn: '遠端開燈',
sending: '下發中…',
enable: '啟用',
disable: '停用',
remove: '刪除',
empty: '暫無照明設備',
foot: '廠家提供資料呼叫介面,本頁負責顯示整合與遠端開關。一期為 Mock 落庫;二期對接照明閘道後即時同步。',
configTitle: '照明通訊設定',
editable: '可編輯',
readonly: '唯讀',
configHint: '對接廠家智慧照明閘道(Modbus / HTTP / TCP)。設定儲存後可用於遠端開關下發。',
host: '閘道位址',
port: '埠',
protocol: '協議',
unitId: '從站位址 (Unit ID)',
username: '使用者名稱(可選)',
password: '密碼(可選)',
pollMs: '狀態輪詢 (ms)',
enableComm: '啟用通訊',
enabled: '已啟用',
disabled: '已關閉',
save: '儲存設定',
testing: '測試中…',
test: '測試連通性',
lastUpdate: '最近更新:{time} · {by}',
loadFail: '載入失敗',
added: '設備已新增',
addFail: '新增失敗',
updateFail: '更新失敗',
confirmDelete: '確認刪除「{name}」?',
deleted: '已刪除',
deleteFail: '刪除失敗',
switchFail: '開關失敗',
confirmAll: '確認遠端{tip}所有可用迴路?',
batchFail: '批次開關失敗',
saved: '通訊設定已儲存',
saveFail: '儲存失敗',
testOk: '連通性測試通過',
testFail: '測試失敗',
fault: '故障',
},
metrics: {
temp: '溫度',
......
......@@ -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, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api/http'
import { formatLocaleTime } from '@/i18n/t'
import { useAuthStore } from '@/stores/auth'
import type { LightCommConfig, LightDevice, LightProtocol, LightStatus } from '@/types/light'
type Tab = 'devices' | 'config'
const { t } = useI18n()
const auth = useAuthStore()
const tab = ref<Tab>('devices')
const loading = ref(false)
......@@ -34,11 +37,11 @@ const cfgForm = reactive({
enabled: true,
})
const statusLabel: Record<LightStatus, string> = {
online: '在线',
offline: '离线',
fault: '故障',
}
const statusLabel = computed<Record<LightStatus, string>>(() => ({
online: t('status.online'),
offline: t('status.offline'),
fault: t('light.fault'),
}))
const onCount = computed(() => devices.value.filter((d) => d.enabled && d.powerOn).length)
const onlineCount = computed(() => devices.value.filter((d) => d.enabled && d.status === 'online').length)
......@@ -62,7 +65,7 @@ async function loadAll() {
cfgForm.pollMs = cfg.pollMs
cfgForm.enabled = cfg.enabled
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
message.value = e instanceof Error ? e.message : t('light.loadFail')
} finally {
loading.value = false
}
......@@ -81,10 +84,10 @@ async function createDevice() {
form.zone = ''
form.remark = ''
form.circuit += 1
message.value = '设备已添加'
message.value = t('light.added')
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '添加失败'
message.value = e instanceof Error ? e.message : t('light.addFail')
}
}
......@@ -99,20 +102,20 @@ async function toggleEnabled(d: LightDevice) {
})
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '更新失败'
message.value = e instanceof Error ? e.message : t('light.updateFail')
}
}
async function removeDevice(d: LightDevice) {
if (!auth.isAdmin) return
if (!confirm(`确认删除「${d.name}」?`)) return
if (!confirm(t('light.confirmDelete', { name: d.name }))) return
message.value = ''
try {
await api(`/api/light/devices/${d.id}`, { method: 'DELETE', token: auth.token })
message.value = '已删除'
message.value = t('light.deleted')
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '删除失败'
message.value = e instanceof Error ? e.message : t('light.deleteFail')
}
}
......@@ -131,7 +134,7 @@ async function remoteSwitch(d: LightDevice, powerOn: boolean) {
if (idx >= 0) devices.value[idx] = r.device
else await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '开关失败'
message.value = e instanceof Error ? e.message : t('light.switchFail')
} finally {
switching.value = ''
}
......@@ -139,8 +142,8 @@ async function remoteSwitch(d: LightDevice, powerOn: boolean) {
async function switchAll(powerOn: boolean) {
if (!auth.isAdmin) return
const tip = powerOn ? '全部开启' : '全部关闭'
if (!confirm(`确认远程${tip}所有可用回路?`)) return
const tip = powerOn ? t('light.allOn') : t('light.allOff')
if (!confirm(t('light.confirmAll', { tip }))) return
switching.value = 'all'
message.value = ''
try {
......@@ -152,7 +155,7 @@ async function switchAll(powerOn: boolean) {
devices.value = r.devices
message.value = r.message
} catch (e) {
message.value = e instanceof Error ? e.message : '批量开关失败'
message.value = e instanceof Error ? e.message : t('light.batchFail')
} finally {
switching.value = ''
}
......@@ -180,9 +183,9 @@ async function saveConfig() {
})
config.value = cfg
cfgForm.password = cfg.passwordMasked || ''
message.value = '通信配置已保存'
message.value = t('light.saved')
} catch (e) {
message.value = e instanceof Error ? e.message : '保存失败'
message.value = e instanceof Error ? e.message : t('light.saveFail')
}
}
......@@ -196,17 +199,16 @@ async function testConfig() {
token: auth.token,
})
testResult.value = `${r.message} · ${r.latencyMs} ms`
message.value = r.ok ? '连通性测试通过' : r.message
message.value = r.ok ? t('light.testOk') : r.message
} catch (e) {
testResult.value = e instanceof Error ? e.message : '测试失败'
testResult.value = e instanceof Error ? e.message : t('light.testFail')
} finally {
testing.value = false
}
}
function fmtTime(t: string | null) {
if (!t) return '—'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
function fmtTime(time: string | null) {
return formatLocaleTime(time)
}
onMounted(loadAll)
......@@ -216,70 +218,74 @@ onMounted(loadAll)
<div class="page-fill light-page">
<div class="light-tabs">
<button type="button" class="tab" :class="{ active: tab === 'devices' }" @click="tab = 'devices'">
设备管理
{{ t('light.tabDevices') }}
</button>
<button type="button" class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">
通信配置
{{ t('light.tabConfig') }}
</button>
<div class="tab-spacer" />
<div class="badge">开启 {{ onCount }}</div>
<div class="badge">在线 {{ onlineCount }} / {{ devices.length }}</div>
<button type="button" class="btn ghost" :disabled="loading" @click="loadAll">刷新</button>
<div class="badge">{{ t('light.onCount', { n: onCount }) }}</div>
<div class="badge">{{ t('light.onlineCount', { n: onlineCount, total: devices.length }) }}</div>
<button type="button" class="btn ghost" :disabled="loading" @click="loadAll">
{{ t('common.refresh') }}
</button>
<span v-if="message" class="msg">{{ message }}</span>
</div>
<template v-if="tab === 'devices'">
<section v-if="auth.isAdmin" class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>添加照明回路</h3></div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('light.addTitle') }}</h3></div>
<div class="head-right">
<button
type="button"
class="btn ghost"
:disabled="!!switching"
@click="switchAll(true)"
>
全部开启
<button type="button" class="btn ghost" :disabled="!!switching" @click="switchAll(true)">
{{ t('light.allOn') }}
</button>
<button
type="button"
class="btn ghost"
:disabled="!!switching"
@click="switchAll(false)"
>
全部关闭
<button type="button" class="btn ghost" :disabled="!!switching" @click="switchAll(false)">
{{ t('light.allOff') }}
</button>
</div>
</div>
<div class="form-grid three">
<div class="field"><label>名称</label><input v-model="form.name" placeholder="如:PCR走廊照明" /></div>
<div class="field"><label>区域</label><input v-model="form.zone" placeholder="房间/分区" /></div>
<div class="field"><label>回路号</label><input v-model.number="form.circuit" type="number" min="1" max="256" /></div>
<div class="field"><label>备注</label><input v-model="form.remark" /></div>
<div class="field">
<label>{{ t('light.name') }}</label>
<input v-model="form.name" :placeholder="t('light.namePh')" />
</div>
<div class="field">
<label>{{ t('light.zone') }}</label>
<input v-model="form.zone" :placeholder="t('light.zonePh')" />
</div>
<div class="field">
<label>{{ t('light.circuit') }}</label>
<input v-model.number="form.circuit" type="number" min="1" max="256" />
</div>
<div class="field">
<label>{{ t('light.remark') }}</label>
<input v-model="form.remark" />
</div>
</div>
<div class="row-actions">
<button type="button" class="btn" @click="createDevice">添加</button>
<button type="button" class="btn" @click="createDevice">{{ t('light.add') }}</button>
</div>
</section>
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>照明设备列表</h3></div>
<div class="badge amber">厂家接口 · Mock</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('light.listTitle') }}</h3></div>
<div class="badge amber">{{ t('light.mockBadge') }}</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>回路</th>
<th>名称</th>
<th>区域</th>
<th>开关</th>
<th>亮度</th>
<th>状态</th>
<th>启用</th>
<th>最近控制</th>
<th>操作</th>
<th>{{ t('light.colCircuit') }}</th>
<th>{{ t('light.colName') }}</th>
<th>{{ t('light.colZone') }}</th>
<th>{{ t('light.colPower') }}</th>
<th>{{ t('light.colBrightness') }}</th>
<th>{{ t('light.colStatus') }}</th>
<th>{{ t('light.colEnabled') }}</th>
<th>{{ t('light.colLast') }}</th>
<th>{{ t('light.colAction') }}</th>
</tr>
</thead>
<tbody>
......@@ -289,12 +295,12 @@ onMounted(loadAll)
<td>{{ d.zone }}</td>
<td>
<span class="pwr" :class="d.powerOn ? 'on' : 'off'">
{{ d.powerOn ? '开' : '关' }}
{{ d.powerOn ? t('screen.switchOn') : t('screen.switchOff') }}
</span>
</td>
<td>{{ d.brightness }}%</td>
<td><span class="st" :class="d.status">{{ statusLabel[d.status] }}</span></td>
<td>{{ d.enabled ? '是' : '否' }}</td>
<td>{{ d.enabled ? t('common.yes') : t('common.no') }}</td>
<td class="muted">
{{ fmtTime(d.lastSwitchAt) }}
<template v-if="d.lastSwitchBy"> · {{ d.lastSwitchBy }}</template>
......@@ -307,7 +313,13 @@ onMounted(loadAll)
:disabled="!auth.isAdmin || !d.enabled || d.status === 'offline' || switching === d.id"
@click="remoteSwitch(d, !d.powerOn)"
>
{{ switching === d.id ? '下发中…' : d.powerOn ? '远程关灯' : '远程开灯' }}
{{
switching === d.id
? t('light.sending')
: d.powerOn
? t('light.remoteOff')
: t('light.remoteOn')
}}
</button>
<button
v-if="auth.isAdmin"
......@@ -315,7 +327,7 @@ onMounted(loadAll)
class="btn ghost"
@click="toggleEnabled(d)"
>
{{ d.enabled ? '禁用' : '启用' }}
{{ d.enabled ? t('light.disable') : t('light.enable') }}
</button>
<button
v-if="auth.isAdmin"
......@@ -323,44 +335,40 @@ onMounted(loadAll)
class="btn danger"
@click="removeDevice(d)"
>
删除
{{ t('light.remove') }}
</button>
</td>
</tr>
<tr v-if="!devices.length">
<td colspan="9" class="empty">暂无照明设备</td>
<td colspan="9" class="empty">{{ t('light.empty') }}</td>
</tr>
</tbody>
</table>
</div>
<p class="foot-hint">
厂家提供数据调用接口,本页负责显示集成与远程开关。一期为 Mock 落库;二期对接照明网关后实时同步。
</p>
<p class="foot-hint">{{ t('light.foot') }}</p>
</section>
</template>
<template v-else>
<section class="panel config-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>照明通信配置</h3></div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('light.configTitle') }}</h3></div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '可编辑' : '只读' }}
{{ auth.isAdmin ? t('light.editable') : t('light.readonly') }}
</div>
</div>
<p class="hint">
对接厂家智能照明网关(Modbus / HTTP / TCP)。配置保存后可用于远程开关下发。
</p>
<p class="hint">{{ t('light.configHint') }}</p>
<div class="form-grid three">
<div class="field">
<label>网关地址</label>
<label>{{ t('light.host') }}</label>
<input v-model="cfgForm.host" :disabled="!auth.isAdmin" placeholder="192.168.x.x" />
</div>
<div class="field">
<label>端口</label>
<label>{{ t('light.port') }}</label>
<input v-model.number="cfgForm.port" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>协议</label>
<label>{{ t('light.protocol') }}</label>
<select v-model="cfgForm.protocol" :disabled="!auth.isAdmin">
<option value="modbus">Modbus TCP</option>
<option value="http">HTTP</option>
......@@ -368,15 +376,15 @@ onMounted(loadAll)
</select>
</div>
<div class="field">
<label>从站地址 (Unit ID)</label>
<label>{{ t('light.unitId') }}</label>
<input v-model.number="cfgForm.unitId" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>用户名(可选)</label>
<label>{{ t('light.username') }}</label>
<input v-model="cfgForm.username" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>密码(可选)</label>
<label>{{ t('light.password') }}</label>
<input
v-model="cfgForm.password"
type="password"
......@@ -386,19 +394,21 @@ onMounted(loadAll)
/>
</div>
<div class="field">
<label>状态轮询 (ms)</label>
<label>{{ t('light.pollMs') }}</label>
<input v-model.number="cfgForm.pollMs" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field switch-field">
<label>启用通信</label>
<label>{{ t('light.enableComm') }}</label>
<label class="switch">
<input v-model="cfgForm.enabled" type="checkbox" :disabled="!auth.isAdmin" />
<span>{{ cfgForm.enabled ? '已启用' : '已关闭' }}</span>
<span>{{ cfgForm.enabled ? t('light.enabled') : t('light.disabled') }}</span>
</label>
</div>
</div>
<div class="row-actions">
<button v-if="auth.isAdmin" type="button" class="btn" @click="saveConfig">保存配置</button>
<button v-if="auth.isAdmin" type="button" class="btn" @click="saveConfig">
{{ t('light.save') }}
</button>
<button
v-if="auth.isAdmin"
type="button"
......@@ -406,11 +416,11 @@ onMounted(loadAll)
:disabled="testing"
@click="testConfig"
>
{{ testing ? '测试中…' : '测试连通性' }}
{{ testing ? t('light.testing') : t('light.test') }}
</button>
<span v-if="testResult" class="msg">{{ testResult }}</span>
<span v-if="config" class="hint inline">
最近更新:{{ fmtTime(config.updatedAt) }} · {{ config.updatedBy }}
{{ t('light.lastUpdate', { time: fmtTime(config.updatedAt), by: config.updatedBy }) }}
</span>
</div>
</section>
......@@ -419,6 +429,7 @@ onMounted(loadAll)
</template>
<style scoped>
.light-page { gap: 12px; }
.light-tabs {
display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
......
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PanelBox from '@/components/screen/PanelBox.vue'
import KpiFlipBoard, { type KpiFlipItem } from '@/components/screen/KpiFlipBoard.vue'
import EChart, { type ECOption } from '@/components/charts/EChart.vue'
......@@ -8,6 +9,7 @@ import { useRealtimeStore } from '@/stores/realtime'
import { multiLineOption, toHistorySeries } from '@/utils/historyChart'
import { compactTooltipBase, pieTooltip, roundNum } from '@/utils/chartTooltip'
const { t, te } = useI18n()
const realtime = useRealtimeStore()
const { data, error } = useScreenDashboard(5000)
......@@ -19,45 +21,44 @@ const kpiItems = computed<KpiFlipItem[]>(() => {
const d = digits.value
if (!d) {
return [
{ key: 'online', label: '设备在线率 %', value: '—', tone: 'device', icon: 'kpi-device' },
{ key: 'cold', label: '冷链正常', value: '—', tone: 'cool', icon: 'kpi-cool' },
{ key: 'env', label: '环境合格区', value: '—', tone: 'access', icon: 'kpi-access' },
{ key: 'alarm', label: '当前告警', value: '—', tone: 'power', icon: 'kpi-power' },
{ key: 'online', label: t('overview.kpiOnline'), value: '—', tone: 'device', icon: 'kpi-device' },
{ key: 'cold', label: t('overview.kpiCold'), value: '—', tone: 'cool', icon: 'kpi-cool' },
{ key: 'env', label: t('overview.kpiEnv'), value: '—', tone: 'access', icon: 'kpi-access' },
{ key: 'alarm', label: t('overview.kpiAlarm'), value: '—', tone: 'power', icon: 'kpi-power' },
]
}
const rate = d.deviceTotal
? ((d.deviceOnline / d.deviceTotal) * 100).toFixed(0)
: '0'
const rate = d.deviceTotal ? ((d.deviceOnline / d.deviceTotal) * 100).toFixed(0) : '0'
const gw = d.gatewayOk ? t('overview.gatewayOk') : t('overview.gatewayDown')
return [
{
key: 'online',
label: '设备在线率 %',
label: t('overview.kpiOnline'),
value: rate,
hint: `${d.deviceOnline}/${d.deviceTotal} · 网关${d.gatewayOk ? '通' : '断'}`,
hint: t('overview.gatewayHint', { online: d.deviceOnline, total: d.deviceTotal, gw }),
tone: 'device',
icon: 'kpi-device',
},
{
key: 'cold',
label: '冷链正常',
label: t('overview.kpiCold'),
value: `${d.fridgeOk}/${d.fridgeTotal}`,
hint: d.fridgeOk === d.fridgeTotal ? '全部达标' : '有关注项',
hint: d.fridgeOk === d.fridgeTotal ? t('overview.allOk') : t('overview.hasWatch'),
tone: 'cool',
icon: 'kpi-cool',
},
{
key: 'env',
label: '环境合格区',
label: t('overview.kpiEnv'),
value: `${d.roomOk}/${d.roomTotal}`,
hint: d.roomOk === d.roomTotal ? '全部正常' : '有关注区',
hint: d.roomOk === d.roomTotal ? t('overview.allNormal') : t('overview.hasWatchArea'),
tone: 'access',
icon: 'kpi-access',
},
{
key: 'alarm',
label: '当前告警',
label: t('overview.kpiAlarm'),
value: String(d.alarmOpen),
hint: d.alarmOpen ? '值班跟进中' : '无未闭环',
hint: d.alarmOpen ? t('overview.dutyFollow') : t('overview.noOpenAlarm'),
tone: d.alarmOpen ? 'power' : 'device',
icon: 'kpi-power',
},
......@@ -67,6 +68,27 @@ const kpiItems = computed<KpiFlipItem[]>(() => {
const chartText = '#9ec8dc'
const chartMuted = 'rgba(158,200,220,0.35)'
function statusLabel(key: string | undefined, fallback: string) {
if (key && te(`status.${key}`)) return t(`status.${key}`)
const map: Record<string, string> = {
在线: t('status.online'),
告警: t('status.alarm'),
离线: t('status.offline'),
未知: t('status.unknown'),
}
return map[fallback] || fallback
}
function categoryLabel(category: string, fallback: string) {
if (te(`deviceCat.${category}`)) return t(`deviceCat.${category}`)
return fallback
}
function pauLabel(key: string | undefined, fallback: string) {
if (key && te(`pau.${key}`)) return t(`pau.${key}`)
return fallback
}
/** 左1:环境气泡图 温度×湿度,大小=|压差| */
const envBubbleOption = computed<ECOption>(() => {
const rows = (data.value?.envBubbles || []).filter(
......@@ -88,8 +110,13 @@ const envBubbleOption = computed<ECOption>(() => {
if (!d?.value) return ''
return (
`<div style="font-size:11px;color:#e8fbff;font-weight:600">${d.name ?? ''}</div>` +
`<div style="font-size:10px;margin-top:2px">温度 ${roundNum(d.value[0])}℃ · 湿度 ${roundNum(d.value[1])}%</div>` +
`<div style="font-size:10px">压差 ${roundNum(d.pressure)} Pa</div>`
`<div style="font-size:10px;margin-top:2px">${t('overview.tipTemp', {
temp: roundNum(d.value[0]),
humidity: roundNum(d.value[1]),
})}</div>` +
`<div style="font-size:10px">${t('overview.tipPressure', {
pressure: roundNum(d.pressure),
})}</div>`
)
},
},
......@@ -131,12 +158,10 @@ const envBubbleOption = computed<ECOption>(() => {
const curves = computed(() => data.value?.curves)
/** 左1 副:房间温度 24h 曲线 */
const roomTempHistoryOption = computed<ECOption>(() =>
multiLineOption(curves.value?.roomTemp || [], { unit: '℃', maxSeries: 10 }),
)
/** 左2:冷链多系列温度曲线 */
const coldLineOption = computed<ECOption>(() => {
const fromCurves = curves.value?.fridgeTemp || []
if (fromCurves.length) return multiLineOption(fromCurves, { unit: '℃', maxSeries: 10 })
......@@ -148,16 +173,21 @@ const coldLineOption = computed<ECOption>(() => {
const coldBadge = computed(() => {
const d = digits.value
if (!d) return { text: '采集中', amber: false }
if (d.fridgeTotal === 0) return { text: '无冰箱设备', amber: true }
if (d.fridgeOk < d.fridgeTotal) return { text: `${d.fridgeTotal - d.fridgeOk}台关注`, amber: true }
return { text: '全部正常', amber: false }
if (!d) return { text: t('overview.collecting'), amber: false }
if (d.fridgeTotal === 0) return { text: t('overview.noFridge'), amber: true }
if (d.fridgeOk < d.fridgeTotal) {
return { text: t('overview.fridgeWatch', { n: d.fridgeTotal - d.fridgeOk }), amber: true }
}
return { text: t('overview.allNormal'), amber: false }
})
/** 左3:告警 + 设备状态饼 */
const statusPieOption = computed<ECOption>(() => {
const pie = data.value?.statusPie || []
const colors: Record<string, string> = {
online: '#52c41a',
alarm: '#faad14',
offline: '#ff4d4f',
unknown: '#5a7a90',
在线: '#52c41a',
告警: '#faad14',
离线: '#ff4d4f',
......@@ -176,9 +206,9 @@ const statusPieOption = computed<ECOption>(() => {
animation: false,
label: { color: chartText, fontSize: 10, formatter: '{b}\n{c}' },
data: pie.map((p) => ({
name: p.name,
name: statusLabel(p.key, p.name),
value: p.value,
itemStyle: { color: colors[p.name] || '#37c8ff' },
itemStyle: { color: colors[p.key || ''] || colors[p.name] || '#37c8ff' },
})),
},
],
......@@ -200,44 +230,56 @@ const categoryPieOption = computed<ECOption>(() => {
roseType: 'radius',
animation: false,
label: { color: chartText, fontSize: 9, formatter: '{b}' },
data: pie.map((p) => ({ name: p.name, value: p.value })),
data: pie.map((p) => ({
name: categoryLabel(p.category, p.name),
value: p.value,
})),
},
],
}
})
/** 右1:系统状态 + PAU 数字 + VAV 柱 */
const pauCards = computed(() => data.value?.pauCards || [])
const pauCards = computed(() =>
(data.value?.pauCards || []).map((c) => ({
...c,
label: pauLabel(c.key, c.label),
})),
)
/** 右1:HVAC + VAV 历史曲线 */
const systemHistoryOption = computed<ECOption>(() => {
const c = curves.value
const merged = [
...(c?.hvacSupplyTemp || []).map((s) => ({ ...s, name: `${s.name || s.id}·送风温` })),
...(c?.vavAirflow || []).map((s) => ({ ...s, name: `${s.name || s.id}·风量` })),
...(c?.hvacSupplyTemp || []).map((s) => ({
...s,
name: t('overview.seriesSupplyTemp', { name: s.name || s.id }),
})),
...(c?.vavAirflow || []).map((s) => ({
...s,
name: t('overview.seriesAirflow', { name: s.name || s.id }),
})),
]
return multiLineOption(merged, { maxSeries: 8 })
})
/** 右2:压差历史(优先缓冲间) */
const bioHistoryOption = computed<ECOption>(() => {
const all = curves.value?.roomPressure || []
const buf = all.filter((s) => /缓冲|气锁|负压|PCR/i.test(s.name || s.id))
const buf = all.filter((s) => /缓冲|气锁|负压|PCR|buffer|airlock/i.test(s.name || s.id))
return multiLineOption(buf.length ? buf : all, { unit: 'Pa', maxSeries: 10 })
})
const bioOk = computed(() => {
const bars = (data.value?.pressureBars || []).filter((b) => /缓冲|气锁|负压/i.test(b.name))
const bars = (data.value?.pressureBars || []).filter((b) =>
/缓冲|气锁|负压|buffer|airlock/i.test(b.name),
)
return bars.length ? bars.every((b) => b.ok) : true
})
/** 右3:能耗历史(大屏保留;计费管理在后台) */
const energyHistoryOption = computed<ECOption>(() => {
const c = curves.value
const merged = [
...(c?.meteringPower || []).map((s) => ({ ...s, name: '用电 kWh' })),
...(c?.meteringWater || []).map((s) => ({ ...s, name: '用水 m³' })),
...(c?.meteringCooling || []).map((s) => ({ ...s, name: '冷量 kWh' })),
...(c?.meteringPower || []).map((s) => ({ ...s, name: t('overview.seriesPower') })),
...(c?.meteringWater || []).map((s) => ({ ...s, name: t('overview.seriesWater') })),
...(c?.meteringCooling || []).map((s) => ({ ...s, name: t('overview.seriesCooling') })),
]
return multiLineOption(merged, { maxSeries: 6 })
})
......@@ -252,46 +294,51 @@ const energyDigits = computed(() => digits.value)
</div>
<div class="side left">
<PanelBox title="环境总览">
<PanelBox :title="t('overview.envTitle')">
<template #extra>
<span class="badge" :class="{ amber: (digits?.roomOk ?? 0) < (digits?.roomTotal ?? 0) }">
{{ digits ? `${digits.roomOk}/${digits.roomTotal} 合格` : '设备台账' }}
{{
digits
? t('overview.envQualified', { ok: digits.roomOk, total: digits.roomTotal })
: t('overview.deviceLedger')
}}
</span>
</template>
<div class="panel-stack">
<EChart class="chart-main" :option="envBubbleOption" empty-text="房间温湿度待采集" />
<EChart class="chart-main" :option="envBubbleOption" :empty-text="t('overview.envEmpty')" />
<EChart
class="chart-sub"
:option="roomTempHistoryOption"
empty-text="房间温度历史积累中"
:empty-text="t('overview.roomTempEmpty')"
/>
</div>
</PanelBox>
<PanelBox title="冷链监测 · 24h">
<PanelBox :title="t('overview.coldTitle')">
<template #extra>
<span class="badge" :class="{ amber: coldBadge.amber }">{{ coldBadge.text }}</span>
</template>
<EChart
:option="coldLineOption"
empty-text="冰箱温度历史积累中"
/>
<EChart :option="coldLineOption" :empty-text="t('overview.coldEmpty')" />
</PanelBox>
<PanelBox title="设备状态">
<PanelBox :title="t('overview.deviceStatus')">
<template #extra>
<span class="badge" :class="{ amber: (digits?.alarmOpen ?? 0) > 0 }">
{{ digits?.alarmOpen ? `${digits.alarmOpen}项告警` : '运行正常' }}
{{
digits?.alarmOpen
? t('overview.alarmCount', { n: digits.alarmOpen })
: t('overview.runOk')
}}
</span>
</template>
<div class="panel-split panel-split--pies">
<div class="pie-col">
<EChart :option="statusPieOption" empty-text="无设备" />
<div class="pie-cap">在线状态</div>
<EChart :option="statusPieOption" :empty-text="t('overview.noDevice')" />
<div class="pie-cap">{{ t('overview.onlineStatus') }}</div>
</div>
<div class="pie-col">
<EChart :option="categoryPieOption" empty-text="无分类" />
<div class="pie-cap">设备分类</div>
<EChart :option="categoryPieOption" :empty-text="t('overview.noCategory')" />
<div class="pie-cap">{{ t('overview.deviceCats') }}</div>
</div>
</div>
</PanelBox>
......@@ -300,20 +347,31 @@ const energyDigits = computed(() => digits.value)
<div class="center-pass" aria-hidden="true" />
<div class="side right">
<PanelBox title="系统运行状态">
<PanelBox :title="t('overview.systemTitle')">
<template #extra>
<span class="badge" :class="{ amber: !snap.hvac.systemRunning }">
{{ snap.hvac.systemRunning ? '机组运行' : '未启动/无信号' }}
{{ snap.hvac.systemRunning ? t('overview.unitRunning') : t('overview.unitIdle') }}
</span>
</template>
<div class="panel-stack">
<div class="digit-row">
<div v-for="c in pauCards" :key="c.label" class="digit-card" :class="{ warn: c.ok === false }">
<div
v-for="c in pauCards"
:key="c.key || c.label"
class="digit-card"
:class="{ warn: c.ok === false }"
>
<div class="d-label">{{ c.label }}</div>
<div class="d-value">
<template v-if="c.value === null">—</template>
<template v-else>
{{ c.unit === '' ? (c.value ? '开' : '关') : c.value.toFixed(1) }}
{{
c.unit === ''
? c.value
? t('screen.switchOn')
: t('screen.switchOff')
: c.value.toFixed(1)
}}
<small v-if="c.unit">{{ c.unit }}</small>
</template>
</div>
......@@ -322,43 +380,43 @@ const energyDigits = computed(() => digits.value)
<EChart
class="chart-sub"
:option="systemHistoryOption"
empty-text="机组/VAV 历史积累中"
:empty-text="t('overview.systemEmpty')"
/>
</div>
</PanelBox>
<PanelBox title="生物安全压差 · 24h">
<PanelBox :title="t('overview.bioTitle')">
<template #extra>
<span class="badge" :class="{ amber: !bioOk }">
{{ bioOk ? '联锁正常' : '压差关注' }}
{{ bioOk ? t('overview.interlockOk') : t('overview.pressureWatch') }}
</span>
</template>
<EChart :option="bioHistoryOption" empty-text="压差历史积累中" />
<EChart :option="bioHistoryOption" :empty-text="t('overview.pressureEmpty')" />
</PanelBox>
<PanelBox title="能耗与绿色 · 24h">
<PanelBox :title="t('overview.energyTitle')">
<template #extra>
<span class="badge">时序</span>
<span class="badge">{{ t('overview.timeseries') }}</span>
</template>
<div class="panel-stack">
<div class="energy-digits">
<div class="ed">
<span>总用电</span>
<span>{{ t('overview.powerTotal') }}</span>
<b>{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }} <small>kWh</small></b>
</div>
<div class="ed">
<span>用水</span>
<span>{{ t('overview.water') }}</span>
<b>{{ energyDigits ? energyDigits.waterM3.toFixed(1) : '—' }} <small>m³</small></b>
</div>
<div class="ed">
<span>冷量</span>
<span>{{ t('overview.cooling') }}</span>
<b>{{ energyDigits ? energyDigits.coolingKwh.toFixed(0) : '—' }} <small>kWh</small></b>
</div>
</div>
<EChart
class="chart-main"
:option="energyHistoryOption"
empty-text="能耗历史积累中"
:empty-text="t('overview.energyEmpty')"
/>
</div>
</PanelBox>
......@@ -402,8 +460,12 @@ const energyDigits = computed(() => digits.value)
grid-row: 1 / -1;
grid-template-rows: minmax(0, 1.05fr) minmax(0, 1fr) minmax(0, 0.95fr);
}
.left { grid-column: 1; }
.right { grid-column: 3; }
.left {
grid-column: 1;
}
.right {
grid-column: 3;
}
.center-pass {
grid-column: 2;
grid-row: 2;
......@@ -430,8 +492,14 @@ const energyDigits = computed(() => digits.value)
flex-direction: column;
gap: 6px;
}
.chart-main { flex: 1.2; min-height: 0; }
.chart-sub { flex: 0.9; min-height: 0; }
.chart-main {
flex: 1.2;
min-height: 0;
}
.chart-sub {
flex: 0.9;
min-height: 0;
}
.panel-split {
flex: 1;
min-height: 0;
......@@ -465,8 +533,13 @@ const energyDigits = computed(() => digits.value)
background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.14);
}
.digit-card.warn { border-color: rgba(250, 173, 20, 0.4); }
.d-label { font-size: 10px; color: var(--panel-muted); }
.digit-card.warn {
border-color: rgba(250, 173, 20, 0.4);
}
.d-label {
font-size: 10px;
color: var(--panel-muted);
}
.d-value {
margin-top: 2px;
font-size: 15px;
......@@ -492,7 +565,11 @@ const energyDigits = computed(() => digits.value)
background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.14);
}
.ed span { display: block; font-size: 10px; color: var(--panel-muted); }
.ed span {
display: block;
font-size: 10px;
color: var(--panel-muted);
}
.ed b {
display: block;
margin-top: 4px;
......@@ -500,7 +577,11 @@ const energyDigits = computed(() => digits.value)
font-family: var(--font-num);
color: #fff;
}
.ed small { font-size: 10px; color: rgba(200, 230, 245, 0.65); font-weight: 500; }
.ed small {
font-size: 10px;
color: rgba(200, 230, 245, 0.65);
font-weight: 500;
}
.screen-err {
position: absolute;
left: 50%;
......
<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;
......
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api/http'
import WebRtcPlayer from '@/components/video/WebRtcPlayer.vue'
import { formatLocaleTime } from '@/i18n/t'
import { useAuthStore } from '@/stores/auth'
import type {
DeviceStatus,
......@@ -14,6 +16,7 @@ import type {
type Tab = 'devices' | 'config' | 'preview'
const { t } = useI18n()
const auth = useAuthStore()
const tab = ref<Tab>('devices')
const loading = ref(false)
......@@ -45,16 +48,16 @@ const cfgForm = reactive({
enabled: true,
})
const statusLabel: Record<DeviceStatus, string> = {
online: '在线',
offline: '离线',
alarm: '报警',
}
const statusLabel = computed<Record<DeviceStatus, string>>(() => ({
online: t('status.online'),
offline: t('status.offline'),
alarm: t('status.alarm'),
}))
const typeLabel: Record<DeviceType, string> = {
ipc: '海康网络摄像机',
'nvr-ch': '海康 NVR 通道',
}
const typeLabel = computed<Record<DeviceType, string>>(() => ({
ipc: t('video.typeIpc'),
'nvr-ch': t('video.typeNvr'),
}))
const onlineCount = computed(() => devices.value.filter((d) => d.enabled && d.status === 'online').length)
const previewList = computed(() => devices.value.filter((d) => d.enabled))
......@@ -83,7 +86,7 @@ async function loadAll() {
cfgForm.enabled = cfg.enabled
if (!selectedId.value && previewList.value[0]) selectedId.value = previewList.value[0].id
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
message.value = e instanceof Error ? e.message : t('video.loadFail')
} finally {
loading.value = false
}
......@@ -103,10 +106,10 @@ async function createDevice() {
form.ip = ''
form.remark = ''
form.channel += 1
message.value = '设备已添加'
message.value = t('video.added')
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '添加失败'
message.value = e instanceof Error ? e.message : t('video.addFail')
}
}
......@@ -121,21 +124,21 @@ async function toggleEnabled(d: VideoDevice) {
})
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '更新失败'
message.value = e instanceof Error ? e.message : t('video.updateFail')
}
}
async function removeDevice(d: VideoDevice) {
if (!auth.isAdmin) return
if (!confirm(`确认删除设备「${d.name}」?`)) return
if (!confirm(t('video.confirmDelete', { name: d.name }))) return
message.value = ''
try {
await api(`/api/video/devices/${d.id}`, { method: 'DELETE', token: auth.token })
if (selectedId.value === d.id) selectedId.value = ''
message.value = '已删除'
message.value = t('video.deleted')
await loadAll()
} catch (e) {
message.value = e instanceof Error ? e.message : '删除失败'
message.value = e instanceof Error ? e.message : t('video.deleteFail')
}
}
......@@ -162,9 +165,9 @@ async function saveConfig() {
})
config.value = cfg
cfgForm.password = cfg.passwordMasked || ''
message.value = '通信配置已保存'
message.value = t('video.saved')
} catch (e) {
message.value = e instanceof Error ? e.message : '保存失败'
message.value = e instanceof Error ? e.message : t('video.saveFail')
}
}
......@@ -178,9 +181,9 @@ async function testConfig() {
token: auth.token,
})
testResult.value = `${r.message} · ${r.latencyMs} ms`
message.value = r.ok ? '连通性测试通过' : r.message
message.value = r.ok ? t('video.testOk') : r.message
} catch (e) {
testResult.value = e instanceof Error ? e.message : '测试失败'
testResult.value = e instanceof Error ? e.message : t('video.testFail')
} finally {
testing.value = false
}
......@@ -191,8 +194,8 @@ function openPreview(d: VideoDevice) {
tab.value = 'preview'
}
function fmtTime(t: string) {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
function fmtTime(time: string) {
return formatLocaleTime(time)
}
onMounted(loadAll)
......@@ -202,65 +205,81 @@ onMounted(loadAll)
<div class="page-fill video-page">
<div class="video-tabs">
<button type="button" class="tab" :class="{ active: tab === 'devices' }" @click="tab = 'devices'">
设备管理
{{ t('video.tabDevices') }}
</button>
<button type="button" class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">
通信配置
{{ t('video.tabConfig') }}
</button>
<button type="button" class="tab" :class="{ active: tab === 'preview' }" @click="tab = 'preview'">
查看监控
{{ t('video.tabPreview') }}
</button>
<div class="tab-spacer" />
<div class="badge">海康威视</div>
<div class="badge">在线 {{ onlineCount }} / {{ devices.length }}</div>
<button type="button" class="btn ghost" :disabled="loading" @click="loadAll">刷新</button>
<div class="badge">{{ t('video.brand') }}</div>
<div class="badge">{{ t('video.onlineCount', { n: onlineCount, total: devices.length }) }}</div>
<button type="button" class="btn ghost" :disabled="loading" @click="loadAll">
{{ t('common.refresh') }}
</button>
<span v-if="message" class="msg">{{ message }}</span>
</div>
<!-- 设备管理 -->
<template v-if="tab === 'devices'">
<section v-if="auth.isAdmin" class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>添加海康监控设备</h3></div>
<div class="badge">管理员</div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('video.addTitle') }}</h3></div>
<div class="badge">{{ t('video.admin') }}</div>
</div>
<div class="form-grid three">
<div class="field"><label>名称</label><input v-model="form.name" placeholder="如:走廊东" /></div>
<div class="field"><label>NVR 通道号</label><input v-model.number="form.channel" type="number" min="1" max="256" /></div>
<div class="field"><label>安装位置</label><input v-model="form.location" placeholder="楼层/区域" /></div>
<div class="field">
<label>类型</label>
<label>{{ t('video.name') }}</label>
<input v-model="form.name" :placeholder="t('video.namePh')" />
</div>
<div class="field">
<label>{{ t('video.channel') }}</label>
<input v-model.number="form.channel" type="number" min="1" max="256" />
</div>
<div class="field">
<label>{{ t('video.location') }}</label>
<input v-model="form.location" :placeholder="t('video.locationPh')" />
</div>
<div class="field">
<label>{{ t('video.type') }}</label>
<select v-model="form.deviceType">
<option value="nvr-ch">海康 NVR 通道</option>
<option value="ipc">海康网络摄像机</option>
<option value="nvr-ch">{{ t('video.typeNvr') }}</option>
<option value="ipc">{{ t('video.typeIpc') }}</option>
</select>
</div>
<div class="field"><label>摄像机 IP(可选)</label><input v-model="form.ip" placeholder="IPC 直连时填写" /></div>
<div class="field"><label>备注</label><input v-model="form.remark" /></div>
<div class="field">
<label>{{ t('video.ip') }}</label>
<input v-model="form.ip" :placeholder="t('video.ipPh')" />
</div>
<div class="field">
<label>{{ t('video.remark') }}</label>
<input v-model="form.remark" />
</div>
</div>
<div class="row-actions">
<button type="button" class="btn" @click="createDevice">添加</button>
<button type="button" class="btn" @click="createDevice">{{ t('video.add') }}</button>
</div>
</section>
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>海康监控设备列表</h3></div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('video.listTitle') }}</h3></div>
<div class="badge amber">Hikvision NVR</div>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>通道</th>
<th>名称</th>
<th>位置</th>
<th>类型</th>
<th>IP</th>
<th>状态</th>
<th>启用</th>
<th>更新时间</th>
<th>操作</th>
<th>{{ t('video.colChannel') }}</th>
<th>{{ t('video.colName') }}</th>
<th>{{ t('video.colLocation') }}</th>
<th>{{ t('video.colType') }}</th>
<th>{{ t('video.colIp') }}</th>
<th>{{ t('video.colStatus') }}</th>
<th>{{ t('video.colEnabled') }}</th>
<th>{{ t('video.colUpdated') }}</th>
<th>{{ t('video.colAction') }}</th>
</tr>
</thead>
<tbody>
......@@ -273,17 +292,19 @@ onMounted(loadAll)
<td>
<span class="st" :class="d.status">{{ statusLabel[d.status] }}</span>
</td>
<td>{{ d.enabled ? '是' : '否' }}</td>
<td>{{ d.enabled ? t('common.yes') : t('common.no') }}</td>
<td>{{ fmtTime(d.updatedAt) }}</td>
<td class="ops">
<button type="button" class="btn ghost" @click="openPreview(d)">查看</button>
<button type="button" class="btn ghost" @click="openPreview(d)">
{{ t('video.view') }}
</button>
<button
v-if="auth.isAdmin"
type="button"
class="btn ghost"
@click="toggleEnabled(d)"
>
{{ d.enabled ? '禁用' : '启用' }}
{{ d.enabled ? t('video.disable') : t('video.enable') }}
</button>
<button
v-if="auth.isAdmin"
......@@ -291,12 +312,12 @@ onMounted(loadAll)
class="btn danger"
@click="removeDevice(d)"
>
删除
{{ t('video.remove') }}
</button>
</td>
</tr>
<tr v-if="!devices.length">
<td colspan="9" class="empty">暂无设备,请先添加或检查通信配置</td>
<td colspan="9" class="empty">{{ t('video.empty') }}</td>
</tr>
</tbody>
</table>
......@@ -304,49 +325,45 @@ onMounted(loadAll)
</section>
</template>
<!-- 通信配置 -->
<template v-else-if="tab === 'config'">
<section class="panel config-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>海康通信配置</h3></div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('video.configTitle') }}</h3></div>
<div class="head-right">
<div class="badge">厂商:海康威视</div>
<div class="badge">{{ t('video.vendor') }}</div>
<div class="badge" :class="auth.isAdmin ? '' : 'amber'">
{{ auth.isAdmin ? '可编辑' : '只读' }}
{{ auth.isAdmin ? t('video.editable') : t('video.readonly') }}
</div>
</div>
</div>
<p class="hint">
对接海康威视 NVR / 摄像机。推荐 HCNetSDK(设备端口 8000)预览;RTSP(554)或 ISAPI 作备选。
一期保存配置并 Mock 登录测试;二期经 Windows HCNetSDK 桥接拉取实况(与门禁桥接同类)。
</p>
<p class="hint">{{ t('video.configHint') }}</p>
<div class="form-grid three">
<div class="field">
<label>海康 NVR IP</label>
<label>{{ t('video.nvrIp') }}</label>
<input v-model="cfgForm.host" :disabled="!auth.isAdmin" placeholder="192.168.x.x" />
</div>
<div class="field">
<label>设备端口(SDK)</label>
<label>{{ t('video.sdkPort') }}</label>
<input v-model.number="cfgForm.port" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>RTSP 端口</label>
<label>{{ t('video.rtspPort') }}</label>
<input v-model.number="cfgForm.rtspPort" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>接入方式</label>
<label>{{ t('video.protocol') }}</label>
<select v-model="cfgForm.protocol" :disabled="!auth.isAdmin">
<option value="sdk">HCNetSDK(推荐)</option>
<option value="sdk">{{ t('video.protocolSdk') }}</option>
<option value="rtsp">RTSP</option>
<option value="isapi">ISAPI</option>
</select>
</div>
<div class="field">
<label>设备用户名</label>
<label>{{ t('video.username') }}</label>
<input v-model="cfgForm.username" :disabled="!auth.isAdmin" />
</div>
<div class="field">
<label>设备密码</label>
<label>{{ t('video.password') }}</label>
<input
v-model="cfgForm.password"
type="password"
......@@ -356,26 +373,28 @@ onMounted(loadAll)
/>
</div>
<div class="field">
<label>预览码流</label>
<label>{{ t('video.stream') }}</label>
<select v-model="cfgForm.streamType" :disabled="!auth.isAdmin">
<option value="sub">子码流(推荐)</option>
<option value="main">主码流</option>
<option value="sub">{{ t('video.streamSub') }}</option>
<option value="main">{{ t('video.streamMain') }}</option>
</select>
</div>
<div class="field">
<label>预览超时 (ms)</label>
<label>{{ t('video.timeout') }}</label>
<input v-model.number="cfgForm.previewTimeoutMs" type="number" :disabled="!auth.isAdmin" />
</div>
<div class="field switch-field">
<label>启用海康通信</label>
<label>{{ t('video.enableComm') }}</label>
<label class="switch">
<input v-model="cfgForm.enabled" type="checkbox" :disabled="!auth.isAdmin" />
<span>{{ cfgForm.enabled ? '已启用' : '已关闭' }}</span>
<span>{{ cfgForm.enabled ? t('video.enabled') : t('video.disabled') }}</span>
</label>
</div>
</div>
<div class="row-actions">
<button v-if="auth.isAdmin" type="button" class="btn" @click="saveConfig">保存配置</button>
<button v-if="auth.isAdmin" type="button" class="btn" @click="saveConfig">
{{ t('video.save') }}
</button>
<button
v-if="auth.isAdmin"
type="button"
......@@ -383,24 +402,23 @@ onMounted(loadAll)
:disabled="testing"
@click="testConfig"
>
{{ testing ? '测试中…' : '测试连通性' }}
{{ testing ? t('video.testing') : t('video.test') }}
</button>
<span v-if="testResult" class="msg">{{ testResult }}</span>
<span v-if="config" class="hint">
最近更新:{{ fmtTime(config.updatedAt) }} · {{ config.updatedBy }}
{{ t('video.lastUpdate', { time: fmtTime(config.updatedAt), by: config.updatedBy }) }}
</span>
</div>
</section>
</template>
<!-- 查看监控 -->
<template v-else>
<div class="preview-layout">
<section class="panel preview-main">
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>{{ selected ? selected.name : '未选择通道' }}</h3>
<h3>{{ selected ? selected.name : t('video.noChannel') }}</h3>
</div>
<div v-if="selected" class="badge" :class="selected.status === 'online' ? '' : 'amber'">
CH{{ selected.channel }} · {{ statusLabel[selected.status] }}
......@@ -409,8 +427,8 @@ onMounted(loadAll)
<div class="stage webrtc-stage" :class="selected?.status || 'offline'">
<WebRtcPlayer v-if="selected" :key="selected.id" :device-id="selected.id" />
<div v-else class="stage-body">
<div class="cam-title">无可用通道</div>
<p class="cam-note">请先在设备管理中启用海康监控通道。</p>
<div class="cam-title">{{ t('video.noChannelTitle') }}</div>
<p class="cam-note">{{ t('video.noChannelHint') }}</p>
</div>
<div v-if="selected" class="cam-meta bar">
{{ selected.name }} · {{ selected.location }}
......@@ -423,7 +441,7 @@ onMounted(loadAll)
<section class="panel preview-side">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>通道列表</h3></div>
<div class="panel-title"><div class="vbar" /><h3>{{ t('video.channelList') }}</h3></div>
</div>
<div class="thumb-grid">
<button
......@@ -449,6 +467,7 @@ onMounted(loadAll)
</template>
<style scoped>
.video-page { gap: 12px; }
.head-right { display: flex; align-items: center; gap: 8px; }
.video-tabs {
......
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