Commit 29340069 by James Yuan

feat: 三表能耗展示,主视图飞镜与经纬度点位补打

大屏右下角三表一行+曲线,默认/标题栏/设备分类统一飞「主视图」并在飞完后补打 isGeo 点位;同步电表采集与部署看门狗脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent c29b7735
# JhLab boot: Docker Desktop + compose + Gateway + RTEngine
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$Log = Join-Path $Root 'scripts\boot-all.log'
function Log([string]$m) {
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m
Add-Content -Path $Log -Value $line -Encoding UTF8
Write-Host $line
}
Log '==== boot-all start ===='
# --- 1) Docker Desktop ---
$dockerCandidates = @(
"$env:ProgramFiles\Docker\Docker\resources\bin\docker.exe",
"$env:LOCALAPPDATA\Programs\DockerDesktop\resources\bin\docker.exe",
"$env:LOCALAPPDATA\Docker\resources\bin\docker.exe"
)
$desktopCandidates = @(
"$env:ProgramFiles\Docker\Docker\Docker Desktop.exe",
"$env:LOCALAPPDATA\Programs\DockerDesktop\Docker Desktop.exe"
)
$Docker = $dockerCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Docker) { $Docker = 'docker' }
$Desktop = $desktopCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
$engineOk = $false
try {
& $Docker info 1>$null 2>$null
if ($LASTEXITCODE -eq 0) { $engineOk = $true }
} catch {}
if (-not $engineOk) {
if ($Desktop) {
Log ("starting Docker Desktop: " + $Desktop)
Start-Process -FilePath $Desktop
} else {
Log 'Docker Desktop.exe not found'
}
for ($i = 1; $i -le 90; $i++) {
Start-Sleep -Seconds 5
try {
& $Docker info 1>$null 2>$null
if ($LASTEXITCODE -eq 0) { $engineOk = $true; Log ("docker ready after ${i}*5s"); break }
} catch {}
}
} else {
Log 'docker engine already ready'
}
if (-not $engineOk) {
Log 'ERROR: docker engine not ready'
} else {
# --- 2) Compose stack ---
Push-Location $Root
try {
$envFile = Join-Path $Root '.env'
if (Test-Path $envFile) {
Log 'docker compose up -d'
& $Docker compose --env-file $envFile -f docker-compose.yml up -d 2>&1 | ForEach-Object { Log $_ }
} else {
Log 'docker compose up -d (no .env)'
& $Docker compose -f docker-compose.yml up -d 2>&1 | ForEach-Object { Log $_ }
}
& $Docker compose -f docker-compose.yml ps --format '{{.Name}} {{.Status}}' 2>&1 | ForEach-Object { Log $_ }
} finally {
Pop-Location
}
}
# --- 3) Gateway (38080) ---
$gwListen = netstat -ano | findstr ':38080' | findstr 'LISTENING'
$gwProc = Get-Process -Name 'JhLabGateway' -ErrorAction SilentlyContinue
if ($gwListen -or $gwProc) {
Log 'gateway already running'
} else {
$gwExe = Join-Path $Root 'gateway\JhLabGateway.exe'
$gwCfg = Join-Path $Root 'gateway\config.json'
if (Test-Path $gwExe) {
Log 'starting JhLabGateway'
Start-Process -FilePath $gwExe -ArgumentList $gwCfg -WorkingDirectory (Join-Path $Root 'gateway') -WindowStyle Minimized
} else {
Log 'ERROR: JhLabGateway.exe missing'
}
}
# --- 4) RTEngine: 单一实例 + 镜湖场景可加载 ---
Log 'ensure RTEngine single-instance + scene'
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'scripts\ensure-rtengine.ps1')
# --- 5) 自动打开大屏(需交互桌面;开机登录后可见)---
Log 'open dashboard'
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Root 'scripts\open-dashboard.ps1')
Log '==== boot-all done ===='
# Ensure RTEngine: single UE instance, shared stream, scene ready
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$Log = Join-Path $Root 'scripts\ensure-rtengine.log'
function Log([string]$m) {
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m
Add-Content -Path $Log -Value $line -Encoding UTF8
Write-Host $line
}
$cfgPath = Join-Path $env:APPDATA 'rtenginehd-electron-editor\config.json'
$roamingScene = Join-Path $env:APPDATA 'rtenginehd-electron-editor\designer-data\scenes\mvlpj2yt6379aa29.json'
$signalScene = 'C:\Users\admin\AppData\Local\Programs\RTEngineHD Editor\resources\signal\designer\data\scenes\mvlpj2yt6379aa29.json'
$backupScene = 'D:\apps\.__rtenginehd_runtime_preserve_deploy\designer-data\scenes\mvlpj2yt6379aa29.json'
$rtExe = 'C:\Users\admin\AppData\Local\Programs\RTEngineHD Editor\RTEngineHD Editor.exe'
$sceneId = 'mvlpj2yt6379aa29'
function Lock-Config {
if (-not (Test-Path $cfgPath)) { return }
$raw = Get-Content -Raw -Encoding UTF8 $cfgPath
$raw2 = $raw
$raw2 = [regex]::Replace($raw2, '"address"\s*:\s*"[^"]*"', '"address": "192.168.10.107"')
# maxInstance=1 single UE; one2one=false share same picture to all clients
$raw2 = [regex]::Replace($raw2, '"one2one"\s*:\s*true', '"one2one": false')
$raw2 = [regex]::Replace($raw2, '"maxInstance"\s*:\s*\d+', '"maxInstance": 1')
if ($raw2 -ne $raw) {
[System.IO.File]::WriteAllText($cfgPath, $raw2, (New-Object System.Text.UTF8Encoding $false))
Log 'config locked: address=192.168.10.107 one2one=false maxInstance=1'
}
}
# Fast path: if single UE already running, do NOT respawn
$ueRunning = @(Get-Process -Name 'RTEngineHD' -ErrorAction SilentlyContinue)
$listenLines = @(netstat -ano | findstr ':6088' | findstr LISTENING)
if ($listenLines.Count -gt 0 -and $ueRunning.Count -ge 1) {
Lock-Config
Log ("skip restart: keep single UE pid=" + (($ueRunning | ForEach-Object { $_.Id }) -join ','))
exit 0
}
Lock-Config
# Ensure scene file
$cands = @($roamingScene, $signalScene, $backupScene) | Where-Object { Test-Path $_ } | ForEach-Object { Get-Item $_ }
$best = $cands | Sort-Object Length -Descending | Select-Object -First 1
if (-not $best -or $best.Length -lt 100000) {
Log 'ERROR: no healthy scene backup'
} else {
New-Item -ItemType Directory -Force -Path (Split-Path $roamingScene) | Out-Null
if (-not (Test-Path $roamingScene) -or (Get-Item $roamingScene).Length -lt 100000) {
Copy-Item -Force $best.FullName $roamingScene
Log ("restored roaming scene size=" + $best.Length)
} else {
Log ("roaming scene ok size=" + (Get-Item $roamingScene).Length)
}
$idx = Join-Path $env:APPDATA 'rtenginehd-electron-editor\designer-data\scenes.json'
if (-not (Test-Path $idx) -or ((Get-Content -Raw $idx) -notmatch $sceneId)) {
$json = @"
[
{
"id": "$sceneId",
"name": "jinghu",
"thumbnail": "mvlpj2yt6379aa29.jpg",
"createdAt": "2026-10-24T01:15:32.934Z",
"updatedAt": "2026-10-26T13:58:55.915Z",
"createdBy": "admin"
}
]
"@
[System.IO.File]::WriteAllText($idx, $json, (New-Object System.Text.UTF8Encoding $false))
Log 'scenes.json rewritten'
}
}
# Start editor/signal only if 6088 is down
$listenLines = @(netstat -ano | findstr ':6088' | findstr LISTENING)
if ($listenLines.Count -eq 0) {
if (-not (Test-Path $rtExe)) { Log 'ERROR: RTEngine exe missing'; exit 1 }
Log 'starting RTEngineHD Editor (single stack)'
Start-Process -FilePath $rtExe -WindowStyle Minimized
for ($i = 1; $i -le 45; $i++) {
Start-Sleep -Seconds 2
$listenLines = @(netstat -ano | findstr ':6088' | findstr LISTENING)
if ($listenLines.Count -gt 0) { Log ("6088 up after ${i}*2s"); break }
}
} else {
Log '6088 already listening'
}
$ok = $false
for ($i = 1; $i -le 20; $i++) {
try {
$r = Invoke-WebRequest -Uri ("http://127.0.0.1:6088/designer/data/scenes/$sceneId.json") -UseBasicParsing -TimeoutSec 5
if ($r.StatusCode -eq 200 -and $r.RawContentLength -gt 100000) {
Log ("scene API ok size=" + $r.RawContentLength)
$ok = $true
break
}
} catch {
Log ("scene API wait $i")
}
Start-Sleep -Seconds 2
}
if (-not $ok) { Log 'ERROR: scene API not healthy'; exit 2 }
$ues = @(Get-Process -Name 'RTEngineHD' -ErrorAction SilentlyContinue)
Log ("UE process count=" + $ues.Count)
Log 'ensure-rtengine done'
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$boot = Join-Path $Root 'scripts\boot-all.ps1'
$watch = Join-Path $Root 'scripts\watch-all.ps1'
$user = $env:USERNAME
Write-Host '=== Docker Desktop Run key ==='
$dd = @(
"$env:ProgramFiles\Docker\Docker\Docker Desktop.exe",
"$env:LOCALAPPDATA\Programs\DockerDesktop\Docker Desktop.exe"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($dd) {
New-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' `
-Name 'Docker Desktop' -Value "`"$dd`" -Autostart" -PropertyType String -Force | Out-Null
Write-Host ("Run key set: " + $dd)
} else {
Write-Host 'Docker Desktop.exe not found for Run key'
}
# Prefer settings.json openAtLogin if present
$settings = Join-Path $env:APPDATA 'Docker\settings-store.json'
$settings2 = Join-Path $env:APPDATA 'Docker\settings.json'
foreach ($sp in @($settings, $settings2)) {
if (Test-Path $sp) {
try {
$j = Get-Content -Raw -Encoding UTF8 $sp | ConvertFrom-Json
$changed = $false
if ($null -ne $j.openAtLogin -and $j.openAtLogin -ne $true) { $j.openAtLogin = $true; $changed = $true }
if ($null -ne $j.AutoStart -and $j.AutoStart -ne $true) { $j.AutoStart = $true; $changed = $true }
if ($changed) {
($j | ConvertTo-Json -Depth 40) | Set-Content -Path $sp -Encoding UTF8
Write-Host ("updated " + $sp)
} else {
Write-Host ("checked " + $sp)
}
} catch {
Write-Host ("skip settings parse: " + $sp + " " + $_.Exception.Message)
}
}
}
function Ensure-Task($name, $tr, $sc, $extraArgs='') {
schtasks /Delete /TN $name /F 2>$null | Out-Null
$cmd = "schtasks /Create /TN `"$name`" /TR `"$tr`" /SC $sc /RL HIGHEST /F /RU `"$user`""
if ($extraArgs) { $cmd += " $extraArgs" }
cmd /c $cmd 2>&1 | ForEach-Object { Write-Host $_ }
}
$bootTr = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$boot`""
$watchTr = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$watch`""
Write-Host '=== register boot tasks ==='
# Boot (SYSTEM may not see user Docker; use ONLOGON primary + ONSTART with delay as backup)
Ensure-Task 'JhLabBoot' $bootTr 'ONLOGON'
# Also at startup with delay for when auto-login is used after reboot
schtasks /Delete /TN JhLabBootStart /F 2>$null | Out-Null
schtasks /Create /TN JhLabBootStart /TR $bootTr /SC ONSTART /DELAY 0001:30 /RL HIGHEST /F /RU $user 2>&1 | ForEach-Object { Write-Host $_ }
Write-Host '=== register watchdog (1 min) ==='
Ensure-Task 'JhLabWatch' $watchTr 'MINUTE' '/MO 1'
# Keep legacy names pointing to new stack for compatibility
Ensure-Task 'JhLabStack' $bootTr 'ONLOGON'
schtasks /Delete /TN JhLabStackBoot /F 2>$null | Out-Null
schtasks /Create /TN JhLabStackBoot /TR $bootTr /SC ONSTART /DELAY 0001:30 /RL HIGHEST /F /RU $user 2>&1 | ForEach-Object { Write-Host $_ }
# RTEngine-only helpers: point to watch-all / boot-all
Ensure-Task 'JhLab-RTEngine-Start' $bootTr 'ONLOGON'
Ensure-Task 'JhLab-RTEngine-Watch' $watchTr 'MINUTE' '/MO 1'
schtasks /Delete /TN JhLab-RTEngine-Signal /F 2>$null | Out-Null
Ensure-Task 'JhLabEnsureNative' $watchTr 'MINUTE' '/MO 1'
# Compat bat wrappers
@'
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\boot-all.ps1
'@ | Set-Content (Join-Path $Root 'scripts\boot-stack.bat') -Encoding ASCII
@'
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\watch-all.ps1
'@ | Set-Content (Join-Path $Root 'scripts\ensure-native.bat') -Encoding ASCII
@'
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File D:\apps\jh-lab\scripts\boot-all.ps1
'@ | Set-Content (Join-Path $Root 'scripts\start-stack.bat') -Encoding ASCII
Write-Host '=== listed tasks ==='
Get-ScheduledTask | Where-Object { $_.TaskName -match '^JhLab(Boot|Watch|Stack)|JhLab-RTEngine|JhLabEnsureNative' } |
Select-Object TaskName,State | Format-Table -AutoSize
Write-Host '=== dry-run boot-all now (idempotent) ==='
& powershell -NoProfile -ExecutionPolicy Bypass -File $boot
Write-Host DONE
# Open 大屏 after stack is ready (used at boot / logon)
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$Log = Join-Path $Root 'scripts\boot-all.log'
function Log([string]$m) {
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m
Add-Content -Path $Log -Value $line -Encoding UTF8
Write-Host $line
}
$url = 'http://127.0.0.1/dashboard/overview'
# Wait until web is up (compose nginx)
$ready = $false
for ($i = 1; $i -le 60; $i++) {
try {
$r = Invoke-WebRequest -Uri 'http://127.0.0.1/' -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) {
$ready = $true
Log ("web ready after ${i}s status=" + $r.StatusCode)
break
}
} catch {}
Start-Sleep -Seconds 2
}
if (-not $ready) {
Log 'WARN: web not ready, still opening dashboard'
}
# Prefer Edge; fallback Chrome / default handler
$browsers = @(
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe",
"$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe"
)
$browser = $browsers | Where-Object { Test-Path $_ } | Select-Object -First 1
# Avoid stacking endless tabs on every watchdog run: only open if no existing dashboard window
$already = $false
try {
$procs = Get-CimInstance Win32_Process -Filter "Name='msedge.exe' OR Name='chrome.exe'" -EA SilentlyContinue
foreach ($p in $procs) {
if ($p.CommandLine -and $p.CommandLine -match 'dashboard/overview') { $already = $true; break }
}
} catch {}
if ($already) {
Log 'dashboard already open, skip'
exit 0
}
if ($browser) {
# --new-window + start maximized; kiosk optional via env JHLAB_KIOSK=1
$args = @("--new-window", "--start-maximized", $url)
if ($env:JHLAB_KIOSK -eq '1') {
$args = @("--kiosk", $url)
}
Log ("opening dashboard: $browser $url")
Start-Process -FilePath $browser -ArgumentList $args
} else {
Log ("opening dashboard via shell: $url")
Start-Process $url
}
# JhLab watchdog: keep docker compose / gateway / rtengine alive
$ErrorActionPreference = 'Continue'
$Root = 'D:\apps\jh-lab'
$Docker = @(
"$env:ProgramFiles\Docker\Docker\resources\bin\docker.exe",
"$env:LOCALAPPDATA\Programs\DockerDesktop\resources\bin\docker.exe"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $Docker) { $Docker = 'docker' }
# gateway
if (-not (netstat -ano | findstr ':38080' | findstr 'LISTENING')) {
if (-not (Get-Process -Name 'JhLabGateway' -EA SilentlyContinue)) {
$gw = Join-Path $Root 'gateway\JhLabGateway.exe'
if (Test-Path $gw) {
Start-Process -FilePath $gw -ArgumentList (Join-Path $Root 'gateway\config.json') `
-WorkingDirectory (Join-Path $Root 'gateway') -WindowStyle Minimized
}
}
}
# rtengine: 单一实例 + 场景健康(多端共用同一路推流)
# 同进程调用,勿再起可见 powershell 窗口(计划任务 Hidden 挡不住嵌套 Start)
& (Join-Path $Root 'scripts\ensure-rtengine.ps1') | Out-Null
# docker compose (only if engine is up)
try {
& $Docker info 1>$null 2>$null
if ($LASTEXITCODE -eq 0) {
Push-Location $Root
try {
$need = $false
$ps = & $Docker compose -f docker-compose.yml ps -q 2>$null
if (-not $ps) { $need = $true }
else {
$running = & $Docker compose -f docker-compose.yml ps --status running -q 2>$null
if (-not $running) { $need = $true }
}
if ($need) {
if (Test-Path (Join-Path $Root '.env')) {
& $Docker compose --env-file .env -f docker-compose.yml up -d 1>$null 2>$null
} else {
& $Docker compose -f docker-compose.yml up -d 1>$null 2>$null
}
}
} finally { Pop-Location }
} else {
$desktop = @(
"$env:ProgramFiles\Docker\Docker\Docker Desktop.exe",
"$env:LOCALAPPDATA\Programs\DockerDesktop\Docker Desktop.exe"
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($desktop -and -not (Get-Process -Name 'Docker Desktop' -EA SilentlyContinue)) {
Start-Process -FilePath $desktop
}
}
} catch {}
import type { RealtimeSnapshot } from '../types.js'
import { config } from '../config.js'
import { listDevices } from '../devices/store.js'
import { macauDateStr, recordLabPowerDaily } from '../metering/powerMeters.js'
import { pool, query } from './pool.js'
import { isPgEnabled } from './state.js'
import {
......@@ -17,9 +18,23 @@ let lastSnapshotWriteMs = 0
/** 将一次采集快照入库:时序明细 + 降频整包 */
export async function persistSnapshot(snap: RealtimeSnapshot) {
// 三块总表日差量:无 PG 时也更新内存,保证当日/当月统计可用
try {
const readings = (snap.metering.powerMeters || []).map((m) => ({
key: m.key,
name: m.name,
deviceId: m.deviceId,
energyKwh: m.energyKwh,
powerKw: m.powerKw,
}))
if (readings.length) await recordLabPowerDaily(readings, new Date(snap.ts || Date.now()))
} catch (e) {
console.error('[db] record lab power daily failed', e)
}
if (!isPgEnabled()) return
// 1) 窄表时序(主路径,供大屏曲线高效读取)
// 窄表时序(主路径,供大屏曲线高效读取)
try {
await persistSnapshotTimeseries(snap)
} catch (e) {
......@@ -62,7 +77,8 @@ export async function persistSnapshot(snap: RealtimeSnapshot) {
)
}
const day = ts.toISOString().slice(0, 10)
// 日费用用电量:以三块总表当日差量合计为准(澳门日历日)
const day = macauDateStr(ts)
const power = snap.metering.powerKwh
const water = snap.metering.waterM3
const cooling = snap.metering.coolingKwh
......
......@@ -38,6 +38,20 @@ CREATE TABLE IF NOT EXISTS billing_daily_usage (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
/** 实验室三块总表(PAU201 / E电 / N电)日耗电:由累计读数差量得到 */
CREATE TABLE IF NOT EXISTS energy_daily_meter (
usage_date DATE NOT NULL,
meter_key TEXT NOT NULL,
meter_name TEXT NOT NULL DEFAULT '',
device_id TEXT,
start_kwh NUMERIC(16,3),
end_kwh NUMERIC(16,3),
usage_kwh NUMERIC(14,3) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (usage_date, meter_key)
);
CREATE INDEX IF NOT EXISTS idx_energy_daily_meter_month ON energy_daily_meter (usage_date DESC);
CREATE TABLE IF NOT EXISTS collect_samples (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL,
......
......@@ -312,6 +312,38 @@ export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) {
eng: m.coolingKwh,
},
)
for (const pm of m.powerMeters || []) {
if (pm.energyKwh !== null && Number.isFinite(pm.energyKwh)) {
rows.push({
ts,
seriesId: seriesId('metering', pm.key, 'energy_kwh'),
deviceId: pm.key,
source: 'metering',
metric: 'energy_kwh',
eng: pm.energyKwh,
})
}
if (pm.todayKwh != null && Number.isFinite(pm.todayKwh)) {
rows.push({
ts,
seriesId: seriesId('metering', pm.key, 'today_kwh'),
deviceId: pm.key,
source: 'metering',
metric: 'today_kwh',
eng: pm.todayKwh,
})
}
if (pm.powerKw !== null && Number.isFinite(pm.powerKw)) {
rows.push({
ts,
seriesId: seriesId('metering', pm.key, 'power_kw'),
deviceId: pm.key,
source: 'metering',
metric: 'power_kw',
eng: pm.powerKw,
})
}
}
await insertTsSamples(rows)
}
......
/**
* 实验室三块总表:PAU201电表 / E电电表 / N电电表
* - 记录累计电能读数(时序 + 日表)
* - 按日差量统计当天 / 当月总耗与分项
*/
import type { Device, DevicePoint } from '../devices/types.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
export type LabPowerMeterKey = 'pau201' | 'e_power' | 'n_power'
export type LabPowerMeterReading = {
key: LabPowerMeterKey
name: string
deviceId: string
/** 累计电能 kWh(表计读数) */
energyKwh: number | null
/** 瞬时功率 kW(有则填) */
powerKw: number | null
}
export type LabPowerMeterUsage = {
key: LabPowerMeterKey
name: string
deviceId: string
/** 当日/当月耗电 kWh */
usageKwh: number
/** 最新累计读数 */
energyKwh: number | null
powerKw: number | null
}
export type PowerEnergyPeriod = {
totalKwh: number
meters: LabPowerMeterUsage[]
}
export type PowerEnergyStats = {
date: string
month: string
today: PowerEnergyPeriod
monthPeriod: PowerEnergyPeriod
/** 近 30 天每日总耗(三表合计) */
trend30d: { date: string; label: string; totalKwh: number; pau201: number; ePower: number; nPower: number }[]
}
export const LAB_POWER_METERS: {
key: LabPowerMeterKey
name: string
/** 设备名精确匹配 */
nameExact: string
/** 兼容 id / code */
idHint: RegExp
}[] = [
{ key: 'pau201', name: 'PAU201电表', nameExact: 'PAU201电表', idHint: /pau201_db|pau201/i },
{ key: 'e_power', name: 'E电电表', nameExact: 'E电电表', idHint: /2ate01|ate01/i },
{ key: 'n_power', name: 'N电电表', nameExact: 'N电电表', idHint: /2atn01|atn01/i },
]
/** 累计电能点位(勿匹配「总有功功率」) */
const ENERGY_RE = /用电量|正向有功总电能|有功总电能|有功电能|总电能/
/** 瞬时有功功率 */
const POWER_RE = /总有功功率|^有功功率$|有功功率(?!因数)/
function num(v: number | null | undefined): number | null {
if (v === null || v === undefined) return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function pickEng(points: DevicePoint[], re: RegExp): number | null {
for (const p of points) {
if (!re.test(p.name) && !re.test(p.itemId)) continue
const v = num(p.lastEng)
if (v !== null) return v
}
return null
}
/** 澳门本地日历日 YYYY-MM-DD */
export function macauDateStr(d = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Macau',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
export function macauMonthStr(d = new Date()): string {
return macauDateStr(d).slice(0, 7)
}
function findMeterDevice(devices: Device[], def: (typeof LAB_POWER_METERS)[number]): Device | undefined {
return (
devices.find((d) => d.enabled !== false && d.name === def.nameExact) ||
devices.find((d) => d.enabled !== false && def.idHint.test(d.id)) ||
devices.find((d) => d.enabled !== false && def.idHint.test(d.code || ''))
)
}
/** 从设备点位抽取三块总表读数 */
export function extractLabPowerMeters(
devices: Device[],
byDevice: Map<string, DevicePoint[]>,
): LabPowerMeterReading[] {
return LAB_POWER_METERS.map((def) => {
const d = findMeterDevice(devices, def)
const pts = d ? byDevice.get(d.id) || [] : []
return {
key: def.key,
name: def.name,
deviceId: d?.id || '',
energyKwh: d ? pickEng(pts, ENERGY_RE) : null,
powerKw: d ? pickEng(pts, POWER_RE) : null,
}
})
}
function round3(n: number) {
return +n.toFixed(3)
}
/** 进程内日差量基线(快照同步可读;与 PG 表对齐) */
const memDaily = new Map<string, { start: number; end: number; usage: number }>()
let memHydrated = false
function memKey(date: string, meterKey: string) {
return `${date}|${meterKey}`
}
/** 启动后首次从 PG 灌入近两日基线,避免进程重启丢当日差量 */
export async function ensurePowerMeterHydrated() {
if (memHydrated || !isPgEnabled()) {
memHydrated = true
return
}
memHydrated = true
const today = macauDateStr()
const yest = new Date()
yest.setDate(yest.getDate() - 1)
const yestDate = macauDateStr(yest)
try {
const { rows } = await query<{
usage_date: Date | string
meter_key: string
start_kwh: string | null
end_kwh: string | null
usage_kwh: string
}>(
`SELECT usage_date, meter_key, start_kwh, end_kwh, usage_kwh
FROM energy_daily_meter
WHERE usage_date >= $1::date AND usage_date <= $2::date`,
[yestDate, today],
)
for (const r of rows) {
const date =
typeof r.usage_date === 'string' ? r.usage_date.slice(0, 10) : r.usage_date.toISOString().slice(0, 10)
const start = r.start_kwh != null ? Number(r.start_kwh) : Number(r.end_kwh) || 0
const end = r.end_kwh != null ? Number(r.end_kwh) : start
memDaily.set(memKey(date, r.meter_key), {
start,
end,
usage: Number(r.usage_kwh) || Math.max(0, end - start),
})
}
} catch (e) {
console.warn('[metering] hydrate energy_daily_meter failed', e)
}
}
/**
* 同步更新内存日差量,返回各表今日用量。
* start:已有当日行则保留;否则取昨日 end;再否则用当前读数(当日从 0 起)。
*/
export function applyLabPowerBaselines(
readings: LabPowerMeterReading[],
at = new Date(),
): Map<LabPowerMeterKey, number> {
const date = macauDateStr(at)
const yest = new Date(at.getTime())
yest.setDate(yest.getDate() - 1)
const yestDate = macauDateStr(yest)
const today = new Map<LabPowerMeterKey, number>()
for (const r of readings) {
if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) {
today.set(r.key, memDaily.get(memKey(date, r.key))?.usage || 0)
continue
}
const end = r.energyKwh
const k = memKey(date, r.key)
let row = memDaily.get(k)
if (!row) {
const prev = memDaily.get(memKey(yestDate, r.key))
const start = prev && Number.isFinite(prev.end) ? prev.end : end
row = { start, end, usage: Math.max(0, end - start) }
memDaily.set(k, row)
} else {
row.end = end
row.usage = Math.max(0, end - row.start)
}
today.set(r.key, row.usage)
}
return today
}
/**
* 写入当日累计差量到 PG(并刷新内存)。
*/
export async function recordLabPowerDaily(readings: LabPowerMeterReading[], at = new Date()) {
await ensurePowerMeterHydrated()
const todayMap = applyLabPowerBaselines(readings, at)
if (!isPgEnabled()) return todayMap
const date = macauDateStr(at)
for (const r of readings) {
if (r.energyKwh === null || !Number.isFinite(r.energyKwh)) continue
const row = memDaily.get(memKey(date, r.key))
if (!row) continue
await query(
`INSERT INTO energy_daily_meter (
usage_date, meter_key, meter_name, device_id, start_kwh, end_kwh, usage_kwh, updated_at
) VALUES ($1::date, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (usage_date, meter_key) DO UPDATE SET
meter_name = EXCLUDED.meter_name,
device_id = EXCLUDED.device_id,
end_kwh = EXCLUDED.end_kwh,
usage_kwh = GREATEST(0, EXCLUDED.end_kwh - energy_daily_meter.start_kwh),
updated_at = NOW()`,
[date, r.key, r.name, r.deviceId || null, row.start, row.end, row.usage],
)
}
return todayMap
}
/** 快照用:同步得到今日分项(先尽量 hydrate) */
export function todayUsageFromReadings(readings: LabPowerMeterReading[]): {
totalKwh: number
byKey: Map<LabPowerMeterKey, number>
} {
const byKey = applyLabPowerBaselines(readings)
let totalKwh = 0
for (const v of byKey.values()) totalKwh += v
return { totalKwh: round3(totalKwh), byKey }
}
function emptyMeters(readings?: LabPowerMeterReading[]): LabPowerMeterUsage[] {
return LAB_POWER_METERS.map((def) => {
const r = readings?.find((x) => x.key === def.key)
return {
key: def.key,
name: def.name,
deviceId: r?.deviceId || '',
usageKwh: 0,
energyKwh: r?.energyKwh ?? null,
powerKw: r?.powerKw ?? null,
}
})
}
function periodFromMap(
byKey: Map<LabPowerMeterKey, number>,
readings?: LabPowerMeterReading[],
): PowerEnergyPeriod {
const meters = emptyMeters(readings).map((m) => ({
...m,
usageKwh: round3(byKey.get(m.key) || 0),
}))
const totalKwh = round3(meters.reduce((s, m) => s + m.usageKwh, 0))
return { totalKwh, meters }
}
async function loadUsageBetween(from: string, to: string): Promise<Map<string, Map<LabPowerMeterKey, number>>> {
/** date → meterKey → usage */
const out = new Map<string, Map<LabPowerMeterKey, number>>()
if (!isPgEnabled()) {
for (const [k, row] of memDaily) {
const [date, meterKey] = k.split('|')
if (date < from || date > to) continue
if (!out.has(date)) out.set(date, new Map())
out.get(date)!.set(meterKey as LabPowerMeterKey, row.usage)
}
return out
}
const { rows } = await query<{ usage_date: Date | string; meter_key: string; usage_kwh: string }>(
`SELECT usage_date, meter_key, usage_kwh
FROM energy_daily_meter
WHERE usage_date >= $1::date AND usage_date <= $2::date`,
[from, to],
)
for (const r of rows) {
const date = typeof r.usage_date === 'string' ? r.usage_date.slice(0, 10) : r.usage_date.toISOString().slice(0, 10)
if (!out.has(date)) out.set(date, new Map())
out.get(date)!.set(r.meter_key as LabPowerMeterKey, Number(r.usage_kwh) || 0)
}
return out
}
function monthRange(month: string): { from: string; to: string } {
const [y, m] = month.split('-').map(Number)
const from = `${month}-01`
const last = new Date(Date.UTC(y, m, 0)).getUTCDate()
const to = `${month}-${String(last).padStart(2, '0')}`
return { from, to }
}
function sumKeys(dayMap: Map<LabPowerMeterKey, number> | undefined): Map<LabPowerMeterKey, number> {
const m = new Map<LabPowerMeterKey, number>()
for (const def of LAB_POWER_METERS) m.set(def.key, dayMap?.get(def.key) || 0)
return m
}
function accumulateDays(
byDate: Map<string, Map<LabPowerMeterKey, number>>,
from: string,
to: string,
): Map<LabPowerMeterKey, number> {
const acc = new Map<LabPowerMeterKey, number>()
for (const def of LAB_POWER_METERS) acc.set(def.key, 0)
for (const [date, day] of byDate) {
if (date < from || date > to) continue
for (const def of LAB_POWER_METERS) {
acc.set(def.key, (acc.get(def.key) || 0) + (day.get(def.key) || 0))
}
}
return acc
}
/** 当天 / 当月总耗与分项;可选附带最新读数 */
export async function getPowerEnergyStats(readings?: LabPowerMeterReading[]): Promise<PowerEnergyStats> {
await ensurePowerMeterHydrated()
if (readings?.length) applyLabPowerBaselines(readings)
const date = macauDateStr()
const month = macauMonthStr()
const { from: monthFrom, to: monthTo } = monthRange(month)
const start30 = new Date()
start30.setDate(start30.getDate() - 29)
const from30 = macauDateStr(start30)
const byDate = await loadUsageBetween(from30 < monthFrom ? from30 : monthFrom, date > monthTo ? date : monthTo)
// 当日以进程内差量为准(比库更新更及时)
const memToday = new Map<LabPowerMeterKey, number>()
for (const def of LAB_POWER_METERS) {
const row = memDaily.get(memKey(date, def.key))
memToday.set(def.key, row?.usage ?? byDate.get(date)?.get(def.key) ?? 0)
}
byDate.set(date, memToday)
const today = periodFromMap(sumKeys(byDate.get(date)), readings)
const monthPeriod = periodFromMap(accumulateDays(byDate, monthFrom, date), readings)
const trend30d: PowerEnergyStats['trend30d'] = []
const cur = new Date(from30 + 'T12:00:00+08:00')
const end = new Date(date + 'T12:00:00+08:00')
while (cur <= end) {
const d = macauDateStr(cur)
const day = sumKeys(byDate.get(d))
const pau201 = round3(day.get('pau201') || 0)
const ePower = round3(day.get('e_power') || 0)
const nPower = round3(day.get('n_power') || 0)
trend30d.push({
date: d,
label: d.slice(5),
totalKwh: round3(pau201 + ePower + nPower),
pau201,
ePower,
nPower,
})
cur.setDate(cur.getDate() + 1)
}
return { date, month, today, monthPeriod, trend30d }
}
/** 供快照:当日三表差量合计(无记录则 0) */
export async function todayTotalUsageKwh(): Promise<number> {
const s = await getPowerEnergyStats()
return s.today.totalKwh
}
......@@ -137,12 +137,38 @@ export function generateSnapshot(): RealtimeSnapshot {
scene: { x: 133.543, y: 22.1972, z: 51 },
})),
metering: {
powerKwh: jitter(486.2, 2),
powerKwh: jitter(42.6, 1.2),
waterM3: jitter(12.8, 0.2),
coolingKwh: jitter(1286, 8, 0),
powerTrendPct: 4.2,
waterTrendPct: -1.8,
coolingTrendPct: 6.5,
powerMeters: [
{
key: 'pau201',
name: 'PAU201电表',
deviceId: 'mb-103-pau201_db',
energyKwh: jitter(12840, 2),
powerKw: jitter(18.5, 0.8),
todayKwh: jitter(28.4, 0.6),
},
{
key: 'e_power',
name: 'E电电表',
deviceId: 'mb-103-2ate01_db',
energyKwh: jitter(3260, 1),
powerKw: jitter(2.1, 0.2),
todayKwh: jitter(4.2, 0.3),
},
{
key: 'n_power',
name: 'N电电表',
deviceId: 'mb-103-2atn01_db',
energyKwh: jitter(5120, 1.5),
powerKw: jitter(6.8, 0.4),
todayKwh: jitter(10.1, 0.4),
},
],
},
access: {
doors: DOORS.map((d, i) => ({
......
import { evaluatePointAlarms } from '../devices/alarms.js'
import { listDevices, listGateways, listPoints } from '../devices/store.js'
import type { Device, DevicePoint } from '../devices/types.js'
import {
ensurePowerMeterHydrated,
extractLabPowerMeters,
todayUsageFromReadings,
} from '../metering/powerMeters.js'
import { listSensors } from '../sensors/store.js'
import type {
FridgeSnapshot,
......@@ -270,9 +275,11 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
}
}
const powerMeters = enabled.filter((d) => d.category === 'meter_power')
const waterMeters = enabled.filter((d) => d.category === 'meter_water')
const coolMeters = enabled.filter((d) => d.category === 'meter_cooling')
const labPowerReadings = extractLabPowerMeters(enabled, byDevice)
await ensurePowerMeterHydrated()
const { totalKwh: todayTotal, byKey: todayByKey } = todayUsageFromReadings(labPowerReadings)
let onlineCount = 0
for (const d of enabled) {
......@@ -300,12 +307,20 @@ export async function buildSnapshotFromDevices(): Promise<RealtimeSnapshot> {
neLights,
sensors: sensorSnapshots,
metering: {
powerKwh: sumEng(powerMeters, byDevice, /用电量|有功电能|总有功/),
powerKwh: todayTotal,
waterM3: sumEng(waterMeters, byDevice, /用水量|累计流量|流量/),
coolingKwh: sumEng(coolMeters, byDevice, /冷量|累计|用电量/),
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
powerMeters: labPowerReadings.map((r) => ({
key: r.key,
name: r.name,
deviceId: r.deviceId,
energyKwh: r.energyKwh,
powerKw: r.powerKw,
todayKwh: todayByKey.get(r.key) || 0,
})),
},
access: {
doors: enabled
......
......@@ -226,9 +226,10 @@ export async function buildScreenDashboard(): Promise<ScreenDashboard> {
const powerBars = enabled
.filter((d) => d.category === 'meter_power')
.filter((d) => /PAU201电表|E电电表|N电电表/.test(d.name))
.map((d) => ({
name: d.name,
value: engOf(byDevice.get(d.id) || [], /用电量|有功电能/),
value: engOf(byDevice.get(d.id) || [], /用电量|正向有功总电能|有功总电能|有功电能/),
}))
const roomDevs = enabled.filter((d) => d.category === 'room')
......
......@@ -42,6 +42,7 @@ let snapshot: RealtimeSnapshot = {
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
powerMeters: [],
},
access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
......
......@@ -7,6 +7,8 @@ import {
getReport,
type TariffSettings,
} from '../billing/store.js'
import { getPowerEnergyStats } from '../metering/powerMeters.js'
import { getSnapshot } from '../realtime/store.js'
export const billingRouter = Router()
......@@ -53,6 +55,18 @@ billingRouter.get('/stats', async (_req, res) => {
res.json(await getStats())
})
/** PAU201 / E电 / N电:当天与当月总耗 + 分项 */
billingRouter.get('/power-energy', async (_req, res) => {
const readings = (getSnapshot().metering.powerMeters || []).map((m) => ({
key: m.key,
name: m.name,
deviceId: m.deviceId,
energyKwh: m.energyKwh,
powerKw: m.powerKw,
}))
res.json(await getPowerEnergyStats(readings))
})
billingRouter.get('/report', async (req, res) => {
const to = String(req.query.to || new Date().toISOString().slice(0, 10))
let from = String(req.query.from || '')
......
......@@ -142,12 +142,22 @@ export interface RealtimeSnapshot {
/** 传感器聚合(压差等:多点位 → 单打点) */
sensors?: SensorSnapshot[]
metering: {
/** 当日三块总表耗电合计 kWh(差量);无基线时为 0 */
powerKwh: number
waterM3: number
coolingKwh: number
powerTrendPct: number
waterTrendPct: number
coolingTrendPct: number
/** PAU201 / E电 / N电 分项(累计读数 + 当日差量) */
powerMeters?: {
key: 'pau201' | 'e_power' | 'n_power'
name: string
deviceId: string
energyKwh: number | null
powerKw: number | null
todayKwh: number
}[]
}
access: {
doors: DoorSnapshot[]
......
......@@ -16,6 +16,7 @@ import AppIcon from '@/components/icons/AppIcon.vue'
import MarkerNearDialog from '@/components/scene/MarkerNearDialog.vue'
import { api } from '@/api/http'
import {
defaultRtengineUrl,
preloadRtengineIcons,
useRtengineBridge,
type SceneMarkerHit,
......@@ -44,15 +45,8 @@ const { mode: activeMode } = storeToRefs(screenMode)
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
/**
* RTEngineHD 云渲染地址(默认同域 /gis 反代,便于 postMessage)。
* 信令走 /peer-stream、书签走 /designer(见 vite/nginx 代理);
* WebRTC 媒体仍由 ICE 直连引擎机,一般不黑屏。
*/
const cloudUrl =
(import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
'/gis/gis.html?scene=mvlpj2yt6379aa29'
/** LAN 直连 :6088(同一路 UE);避免 /gis 反代下 peer-stream WS 绑错导致黑屏 */
const cloudUrl = defaultRtengineUrl()
const useCloudFrame = computed(
() =>
......@@ -97,7 +91,7 @@ const lastIframePointer = ref<{ x: number; y: number } | null>(null)
const floatTargetReady = ref(false)
let markersSynced = false
let engineBootstrapping = false
/** 就绪管线只跑一次,避免重复 addMarkers 导致光照/场景闪变 */
/** 就绪管线只跑一次 */
let engineReadyDone = false
let iframePointerBound: Document | null = null
......@@ -408,33 +402,44 @@ async function onLightControl(on: boolean) {
}
}
/** 引擎就绪后:预加载图标 → 按分类同步设施 POI */
/** 底栏「全景总览」飞到场景书签「主视图」 */
const OVERVIEW_BOOKMARK = '主视图'
/** 引擎就绪默认视角(与底栏主视图可不同) */
const DEFAULT_BOOKMARK = '全景总览'
/** 默认 / 底栏「全景总览」/ 标题栏 / 设备分类:统一飞场景书签「主视图」 */
const DEFAULT_BOOKMARK = '主视图'
/** 飞书签会清引擎 Marker,须在飞完后再补打 */
let flyRepaintTimer: number | undefined
function scheduleMarkersAfterFly(rebuild = false) {
window.clearTimeout(flyRepaintTimer)
flyRepaintTimer = window.setTimeout(() => {
if (!modePoisBootstrapped.value) return
void (rebuild ? syncModePoisRebuild() : syncModePois())
// 飞行动画尾段可能再清一次,二次加固
window.setTimeout(() => {
if (!modePoisBootstrapped.value) return
void syncModePois()
}, 900)
}, 900)
}
/** 引擎就绪后:先飞「主视图」,再开 POI 打点(避免飞镜清点) */
async function onEngineReady() {
if (!useCloudFrame.value || engineReadyDone || engineBootstrapping) return
engineBootstrapping = true
try {
// 图标预载与拉摄像台账并行,缩短首屏等待
const iconsP = preloadRtengineIcons(undefined, cloudUrl).catch(() => undefined)
const camsP = loadCameras()
screenMode.setMode('pressure')
modePoisBootstrapped.value = true
flyToViewpoint(DEFAULT_BOOKMARK)
await Promise.all([iconsP, camsP])
await preloadRtengineIcons(undefined, cloudUrl).catch(() => undefined)
bridge.showScenePresets()
await loadCameras()
syncCameraMarkers(true)
applyCameraVisibility()
// 经纬度 Marker:与飞镜并行打点,不再人为空等 2.8s
screenMode.setMode('pressure')
await syncModePoisRebuild()
// 先飞默认视点;等飞稳再开 POI,否则会被引擎清掉
flyToHome(true, false)
await new Promise<void>((r) => window.setTimeout(r, 900))
modePoisBootstrapped.value = true
await syncModePois()
scheduleMarkersAfterFly(false)
window.setTimeout(() => {
if (!markersSynced) syncCameraMarkers(true)
applyCameraVisibility()
void syncModePoisRebuild()
}, 1200)
}, 2000)
} catch {
modePoisBootstrapped.value = true
syncCameraMarkers(true)
......@@ -444,27 +449,15 @@ async function onEngineReady() {
}
}
/** 监控图标已改由设施 sensors(useScreenModePois)按分类显示,视频台账点仅用于点击预览匹配,不再向场景打点 */
/** 监控图标已由设施 sensors 按分类显示;不再对视频台账 id 调 setMarkerVisible(易打到空 DataChannel) */
function applyCameraVisibility() {
for (const d of cameras.value) {
if (!d.scenePoint) continue
bridge.setMarkerVisible(d.id, false)
}
/* no-op:台账点不再写入场景 */
}
function syncCameraMarkers(force = false) {
if (!useCloudFrame.value) return
if (markersSynced && !force) return
// 卸掉历史视频台账 Marker,避免与设施摄像头重复
for (const d of cameras.value) {
if (!d.scenePoint) continue
try {
bridge.setMarkerVisible(d.id, false)
bridge.removeMarker(d.id)
} catch {
/* ignore */
}
}
// 台账点不再写入场景;仅标记已同步,避免无意义的 removeMarker 打到空 dc
markersSynced = true
}
......@@ -634,28 +627,18 @@ const modePoisBootstrapped = ref(false)
const modePoisEnabled = computed(
() => props.layout === 'fill' && useCloudFrame.value && modePoisBootstrapped.value,
)
const { syncModePoisRebuild } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncModePois, syncModePoisRebuild } = useScreenModePois(
bridge,
modePoisEnabled,
cloudUrl,
)
const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled)
function cameraSceneSig(list: VideoDevice[]) {
return list
.filter((d) => d.enabled && d.scenePoint)
.map((d) => {
const p = d.scenePoint!
return `${d.id}:${p.x},${p.y},${p.z},${p.icon || ''}`
})
.sort()
.join('|')
}
async function loadCameras() {
try {
const res = await api<{ devices: VideoDevice[] }>('/api/video/devices')
const next = res.devices || []
const changed = cameraSceneSig(cameras.value) !== cameraSceneSig(next)
cameras.value = next
// 仅场景打点变化时才允许再次 addMarkers,避免重复写入打光照
if (changed) markersSynced = false
cameras.value = res.devices || []
markersSynced = false
} catch {
/* 大屏允许匿名;失败则无点位 */
}
......@@ -664,7 +647,7 @@ async function loadCameras() {
function onCloudFrameLoad() {
bindIframePointer()
// iframe 文档就绪后稍等信令/场景,再触发一次就绪管线
window.setTimeout(() => bridge.markReady(), 1200)
window.setTimeout(() => bridge.markReady(), 2500)
}
type SmoothControlsLike = {
......@@ -719,16 +702,11 @@ function onViewerLoad() {
})
}
/**
* 平面图主区域。
* fill+云渲染:不订阅 rooms 时序,避免 WS 每秒刷新带动 iframe 所在组件重绘闪一下。
* 嵌入/本地模型:仍合并实时温湿度与告警。
*/
/** 平面图主区域 + 实时测点(按 roomId 关联协议房间) */
const areaList = computed(() => {
const immersive = props.layout === 'fill' && useCloudFrame.value
const live = immersive ? null : realtime.snapshot.rooms
const live = realtime.snapshot.rooms
return SCREEN_AREAS.map((def) => {
const hit = live?.find((r) => r.id === def.roomId)
const hit = live.find((r) => r.id === def.roomId)
return {
id: def.id,
name: def.name,
......@@ -770,23 +748,30 @@ function onSelectArea(areaId: string) {
if (useCloudFrame.value) flyToViewpoint(area.viewpoint)
}
function flyToHome(force = false, repaint = true) {
if (!useCloudFrame.value) return
if (force) bridge.flyToBookmark(DEFAULT_BOOKMARK, { force: true })
else flyToViewpoint(DEFAULT_BOOKMARK)
if (repaint) scheduleMarkersAfterFly(false)
}
function onSelectOverview() {
// 只飞到主视图书签,不改点位显隐(保持当前分类图标)
activeAreaId.value = OVERVIEW_AREA_ID
if (useCloudFrame.value) flyToViewpoint(OVERVIEW_BOOKMARK)
flyToHome(true, true)
}
function onSelectMode(next: ScreenMode) {
// 右侧设备分类:只切换场景图标类型(压差/照明/冰箱…)
// 右侧设备分类:切换图标,并飞到「主视图」;飞完后再卸旧打新
screenMode.setMode(next)
flyToHome(true, false)
applyCameraVisibility()
// 显式触发卸旧打新,避免仅靠 watch 被快照防抖冲掉导致偶发无图标
window.setTimeout(() => {
void syncModePoisRebuild()
}, 0)
scheduleMarkersAfterFly(true)
window.setTimeout(() => applyCameraVisibility(), 300)
}
defineExpose({ flyToHome })
watch(activeMode, () => applyCameraVisibility())
watch(
......@@ -819,7 +804,7 @@ onMounted(() => {
}
if (useCloudFrame.value) {
// 兜底:部分环境下无 sceneEmbedLoaded,延时强制走就绪管线
window.setTimeout(() => bridge.markReady(), 3500)
window.setTimeout(() => bridge.markReady(), 6000)
return
}
const el = viewerRef.value
......@@ -961,18 +946,13 @@ onBeforeUnmount(() => {
.scene, .ue-frame {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
background: #000;
/* 独立合成层,减轻侧栏/图表重绘时连带闪一下 */
transform: translateZ(0);
contain: strict;
content-visibility: auto;
}
.vp-topbar, .vp-tip {
position: absolute; z-index: 5;
display: flex; align-items: center; gap: 10px;
padding: 7px 12px; border-radius: 7px;
background: rgba(8, 26, 61, 0.85);
background: rgba(8, 26, 61, 0.92);
border: 1px solid rgba(91, 212, 255, 0.42);
backdrop-filter: blur(10px);
pointer-events: none;
}
.vp-topbar { left: 14px; top: 14px; }
......@@ -985,9 +965,8 @@ onBeforeUnmount(() => {
max-height: calc(100% - 72px);
display: flex; flex-direction: column;
border-radius: 6px;
background: rgba(6, 18, 42, 0.55);
background: rgba(6, 18, 42, 0.9);
border: 1px solid rgba(91, 212, 255, 0.28);
backdrop-filter: blur(8px);
overflow: hidden;
pointer-events: auto;
}
......@@ -1019,8 +998,7 @@ onBeforeUnmount(() => {
padding: 7px 8px;
border-radius: 4px;
border: 1px solid rgba(91, 212, 255, 0.32);
background: rgba(8, 28, 58, 0.62);
backdrop-filter: blur(6px);
background: rgba(8, 28, 58, 0.9);
color: rgba(235, 245, 255, 0.95);
cursor: pointer;
font-size: 11px;
......@@ -1102,9 +1080,8 @@ onBeforeUnmount(() => {
flex: 0 0 auto;
min-width: 88px;
padding: 8px 12px;
background: rgba(8, 28, 58, 0.55);
background: rgba(8, 28, 58, 0.9);
border: 1px solid rgba(91, 212, 255, 0.32);
backdrop-filter: blur(6px);
}
.vp-room-item :deep(.room-ico) {
width: 12px;
......
......@@ -61,11 +61,10 @@ const list = computed(() => props.items || [])
padding: 10px 12px 12px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.28);
background: linear-gradient(180deg, rgba(8, 36, 68, 0.88), rgba(3, 14, 32, 0.78));
background: linear-gradient(180deg, rgba(8, 36, 68, 0.92), rgba(3, 14, 32, 0.88));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 8px 20px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(8px);
}
.kpi-head {
display: flex;
......
......@@ -58,8 +58,8 @@ const fillId = `titleFill-${useId()}`
min-height: 0;
display: flex;
flex-direction: column;
background: rgba(0, 18, 42, 0.42);
backdrop-filter: blur(8px);
/* 不用 backdrop-filter:叠在云渲染上时鼠标移入会触发整屏闪白 */
background: rgba(0, 14, 36, 0.82);
border: 1px solid rgba(0, 247, 255, 0.12);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
}
......
......@@ -7,15 +7,30 @@ defineProps<{
title?: string
}>()
const emit = defineEmits<{
home: []
}>()
const { t, locale } = useI18n()
/** 中文标题不用伪斜体,避免繁体缺字/合成字形忽大忽小 */
const cjkTitle = computed(() => String(locale.value).startsWith('zh'))
function onTitleHome() {
emit('home')
}
</script>
<template>
<header class="screen-header-hy">
<button
type="button"
class="title-hit"
:aria-label="title || t('screen.defaultTitle')"
@click="onTitleHome"
>
<img class="banner" :src="headerBar" alt="" />
<h1 :class="{ 'is-cjk': cjkTitle }">{{ title || t('screen.defaultTitle') }}</h1>
</button>
<div class="slot-left">
<slot name="left" />
</div>
......@@ -33,12 +48,23 @@ const cjkTitle = computed(() => String(locale.value).startsWith('zh'))
flex-shrink: 0;
pointer-events: auto;
}
.title-hit {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.banner {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
pointer-events: none;
}
h1 {
position: absolute;
......
......@@ -14,7 +14,7 @@ const SPOT_PREFIX = 'spot:'
/** 灯具挂高:相对设施打点 z,抬高后朝下覆盖房间 */
const SPOT_HEIGHT = 4.2
/**
* candela。关自动曝光后场景偏暗;再 ×10 保证整间足够亮。
* candela。照明模式下加大亮度,保证整间足够亮。
* 仅用朝下 spotlight,不用 point light。
*/
const SPOT_INTENSITY_ON = 4_200_000
......@@ -79,7 +79,7 @@ function toSpot(s: SensorSnapshot, on: boolean): RtengineLight {
outerConeAngle: SPOT_OUTER_CONE,
color: isE ? COLOR_E : COLOR_N,
useTemperature: false,
// 关阴影,避免暗部吃光;关自动曝光时更明显
// 关阴影,避免暗部吃光
castShadows: false,
visible: on,
tag0: 'facility-spot',
......
......@@ -120,16 +120,27 @@ export type EnvLightingSnapshot = {
fog: number
}
/** 默认走同域 /gis 反代,避免 iframe postMessage 跨域 */
/** 默认场景;LAN 直连 :6088 与大屏同属单一 UE 推流(不是新开实例) */
export const RTENGINE_SCENE_QUERY = 'scene=mvlpj2yt6379aa29'
export const RTENGINE_PROXY_PATH = '/gis'
/**
* 云渲染地址:
* - 显式 VITE_RTENGINE_URL 优先
* - 否则局域网用 `http(s)://{hostname}:6088/gis.html?scene=…`
* 避免经 /gis 反代时 peer-stream 把 WS 错绑成 `/gis/gis.html` 导致黑屏
* - 仍是同一 signal / 同一 maxInstance=1 的 UE,只是信令路径正确
*/
export function defaultRtengineUrl() {
return (
const fromEnv =
(import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
`${RTENGINE_PROXY_PATH}/gis.html?${RTENGINE_SCENE_QUERY}`
)
(import.meta.env.VITE_UE_STREAM_URL || '').trim()
if (fromEnv) return fromEnv
if (typeof window !== 'undefined' && window.location?.hostname) {
const { protocol, hostname } = window.location
return `${protocol}//${hostname}:6088/gis.html?${RTENGINE_SCENE_QUERY}`
}
return `${RTENGINE_PROXY_PATH}/gis.html?${RTENGINE_SCENE_QUERY}`
}
export function rtengineOrigin(cloudUrl = defaultRtengineUrl()) {
......@@ -161,16 +172,35 @@ export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) {
}
}
/**
* 父页面拉图标必须走同域(nginx `/api/icons` → 6088)。
* iframe 虽直连 :6088,但从 :80 的 dashboard fetch :6088 会 CORS 失败。
*/
export function rtengineParentIconsBase(cloudUrl = defaultRtengineUrl()) {
if (typeof window !== 'undefined' && window.location?.origin) {
try {
const u = new URL(cloudUrl, window.location.href)
if (u.origin !== window.location.origin) {
// 跨域云渲染页:父页用同域 /api/icons 反代
return { origin: window.location.origin, prefix: '' }
}
} catch {
/* fall through */
}
}
return { origin: rtengineOrigin(cloudUrl), prefix: rtengineAssetPrefix(cloudUrl) }
}
export function rtengineIconUrl(file: string, cloudUrl = defaultRtengineUrl()) {
const name = String(file || '').trim().replace(/\\/g, '/').split('/').pop() || ''
if (!name) return ''
const prefix = rtengineAssetPrefix(cloudUrl)
return `${rtengineOrigin(cloudUrl)}${prefix}/api/icons/${encodeURIComponent(name)}`
const { origin, prefix } = rtengineParentIconsBase(cloudUrl)
return `${origin}${prefix}/api/icons/${encodeURIComponent(name)}`
}
export async function fetchRtengineIcons(cloudUrl = defaultRtengineUrl()): Promise<string[]> {
const prefix = rtengineAssetPrefix(cloudUrl)
const res = await fetch(`${rtengineOrigin(cloudUrl)}${prefix}/api/icons`)
const { origin, prefix } = rtengineParentIconsBase(cloudUrl)
const res = await fetch(`${origin}${prefix}/api/icons`)
if (!res.ok) throw new Error(`图标列表加载失败 (${res.status})`)
const data = (await res.json()) as { files?: string[] } | string[]
const files = Array.isArray(data) ? data : data.files || []
......@@ -367,11 +397,32 @@ export function useRtengineBridge(opts: {
}
}
/** 这些指令最终会走 PeerStream DataChannel;dc/ws 未就绪时 gis 内会抛 emitMessage(null) */
const NEEDS_DATA_CHANNEL = new Set([
'addMarker',
'removeMarker',
'clearMarkers',
'updateMarker',
'updateMarkerText',
'setMarkerVisible',
'setPoiVisible',
'showPOIs',
'hidePOIs',
'addLight',
'removeLight',
'updateLight',
'clearLights',
'setLightVisible',
])
/**
* 优先同域直调 $gisObj[type];否则 postMessage
* gis.js 监听:event.data = { type, data }
*/
function send(type: string, data?: unknown) {
if (NEEDS_DATA_CHANNEL.has(type) && !ready.value) {
return
}
const gis = gisObj()
const fn = gis?.[type]
if (typeof fn === 'function') {
......@@ -502,40 +553,37 @@ export function useRtengineBridge(opts: {
}
/**
* 同域直调 $gisObj.flyToBookmark。
* 过早调用时引擎会排队并在书签注册后自动补飞一次;这里不再 600ms/2s 重试,
* 否则每次刷新会连播多次 Changing。
* 飞书签:同域直调 $gisObj;跨域必须 postMessage。
* 注意:iframe 直连 :6088 时读不到 $gisObj,旧逻辑只塞 pending 从不 send → 父页飞镜无效,
* 引擎稍后自己飞近景会清掉已打点。
*/
function flyToBookmark(name: string, options?: Record<string, unknown>) {
const id = String(name || '').trim()
if (!id || recentlyFlew(id)) return
if (!id) return
const force = !!(options && options.force)
if (!force && recentlyFlew(id)) return
const gis = gisObj()
if (gis && typeof gis.flyToBookmark === 'function') {
try {
const ret = options ? gis.flyToBookmark(id, options) : gis.flyToBookmark(id)
const { force: _f, ...rest } = options || {}
const ret =
Object.keys(rest).length > 0 ? gis.flyToBookmark(id, rest) : gis.flyToBookmark(id)
const queued = !!(ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued)
// queued 也算已交给引擎,注册完成后它会自己补飞,不要再打第二次
rememberFly(id)
if (queued) return
return
} catch {
/* 直调失败再排队一次 */
/* 直调失败走 postMessage */
}
}
if (!pendingFlies.includes(id)) pendingFlies.push(id)
void ensureBookmarks().then(() => {
if (recentlyFlew(id)) return
const again = gisObj()
if (!again || typeof again.flyToBookmark !== 'function') return
try {
again.flyToBookmark(id, options)
rememberFly(id)
} catch {
/* 留给 markReady 的一次 flush */
}
})
const { force: _f, ...rest } = options || {}
send(
'flyToBookmark',
Object.keys(rest).length > 0 ? { id, name: id, ...rest } : id,
)
}
function flushPendingFlies() {
......@@ -744,7 +792,7 @@ export function useRtengineBridge(opts: {
const pitch = l.pitch ?? -90
const yaw = l.yaw ?? 0
const roll = l.roll ?? 0
// 与 Marker 同一套场景坐标(厘米/米本地),禁止 isGeo,否则灯会落到错误经纬度看不见
// 设施坐标与 Marker 一样是经纬度(sensor.scene),必须 isGeo:true
return {
id: l.id,
name: l.name || l.id,
......@@ -752,10 +800,15 @@ export function useRtengineBridge(opts: {
lightType: type,
kind: type,
LightType: type,
isGeo: false,
isGeo: true,
x: l.x,
y: l.y,
z: l.z,
lng: l.x,
lat: l.y,
height: l.z,
longitude: l.x,
latitude: l.y,
position: [l.x, l.y, l.z],
yaw,
pitch,
......@@ -817,10 +870,14 @@ export function useRtengineBridge(opts: {
if (l.x != null) {
payload.x = l.x
payload.lng = l.x
payload.longitude = l.x
payload.isGeo = true
}
if (l.y != null) {
payload.y = l.y
payload.lat = l.y
payload.latitude = l.y
payload.isGeo = true
}
if (l.z != null) {
payload.z = l.z
......@@ -913,6 +970,42 @@ export function useRtengineBridge(opts: {
send('setFog', { fog })
}
/** 部分更新场景全局配置(同 setWeather,只传要改的字段) */
function setSceneConfig(cfg: Record<string, unknown>) {
if (!cfg || typeof cfg !== 'object') return
const gis = gisObj() as {
setSceneConfig?: (c: unknown) => unknown
setGlobalConfig?: (c: unknown) => unknown
} | null
if (gis && typeof gis.setSceneConfig === 'function') {
try {
gis.setSceneConfig(cfg)
} catch {
/* postMessage */
}
} else if (gis && typeof gis.setGlobalConfig === 'function') {
try {
gis.setGlobalConfig(cfg)
} catch {
/* postMessage */
}
}
send('setSceneConfig', cfg)
send('setGlobalConfig', cfg)
}
/**
* 任何时候保持自动曝光开启(禁止关闭)。
* 与场景 JSON sceneConfig.autoExposure / enableAutoExposure / eyeAdaptation 对齐。
*/
function ensureAutoExposure() {
setSceneConfig({
autoExposure: true,
enableAutoExposure: true,
eyeAdaptation: true,
})
}
function setLighting(opts: {
sunBrightness?: number
ambientFill?: number
......@@ -1036,9 +1129,10 @@ export function useRtengineBridge(opts: {
/**
* 昼夜切换。
* 夜间默认「关太阳、空气清澈」:关自动曝光后浓雾会吃掉灯光,照明模式必须透光。
* 夜间默认「关太阳、空气清澈」,便于 spotlight 照亮房间。
* opts.denseFog=true 才恢复旧的浓雾全黑(一般不用)。
* 离开夜景:恢复场景初始环境,而不是假白天 12:00。
* 照明模式禁止再碰自动曝光(不关、也不反复 setSceneConfig 拨开关)。
*/
function applyDayNight(night: boolean, opts?: { denseFog?: boolean }) {
if (night) {
......@@ -1076,27 +1170,69 @@ export function useRtengineBridge(opts: {
}
}
let presetsShown = false
/** 显示场景内预设 POI / Marker(合并为一次,减少场景刷新/光照闪变) */
/** 显示场景内预设 POI / Marker */
function showScenePresets() {
if (presetsShown) return
presetsShown = true
send('setMarkerVisible', { visible: true })
send('showPOIs', { visible: true })
}
/**
* 禁用右键旋转准星 HUD(#rt-orbit-pivot-hud)。
* 该层由 gis.js `_paintOrbitPivotHud` 本地 DOM 绘制,不是第二路推流;
* 叠在 WebRTC video 上时容易触发整屏闪白/闪黑,而服务端通道不变。
*/
function suppressOrbitPivotHud() {
try {
const doc = opts.iframeRef.value?.contentDocument
if (doc) {
let style = doc.getElementById('jh-hide-orbit-pivot')
if (!style) {
style = doc.createElement('style')
style.id = 'jh-hide-orbit-pivot'
style.textContent =
'#rt-orbit-pivot-hud{display:none!important;visibility:hidden!important;opacity:0!important;pointer-events:none!important;width:0!important;height:0!important}'
;(doc.head || doc.documentElement)?.appendChild(style)
}
const hud = doc.getElementById('rt-orbit-pivot-hud')
if (hud) {
hud.style.display = 'none'
hud.style.visibility = 'hidden'
hud.style.opacity = '0'
}
}
const gis = gisObj() as GisObj | null
if (gis && typeof gis._paintOrbitPivotHud === 'function') {
gis._paintOrbitPivotHud = () => {}
}
} catch {
/* 跨域直连 :6088 时读不到 contentDocument,只能靠同域 /gis */
}
}
let readyNotified = false
let flyFlushed = false
function markReady() {
if (readyNotified) return
suppressOrbitPivotHud()
if (readyNotified) {
ensureAutoExposure()
void ensureBookmarks().then(() => flushPendingFlies())
return
}
readyNotified = true
ready.value = true
// 引擎就绪后尽快记下初始光线,供离开照明时还原
ensureAutoExposure()
// 引擎就绪后记下初始光线,供离开照明时还原
window.setTimeout(() => captureEnvironmentLighting(), 800)
// 场景异步落盘后再钉一次,防止配置被场景文件覆盖成关曝光
window.setTimeout(() => ensureAutoExposure(), 1200)
window.setTimeout(() => ensureAutoExposure(), 3000)
window.setTimeout(() => suppressOrbitPivotHud(), 500)
window.setTimeout(() => suppressOrbitPivotHud(), 2000)
opts.onReady?.()
if (flyFlushed) return
flyFlushed = true
void ensureBookmarks().then(() => flushPendingFlies())
window.setTimeout(() => {
void ensureBookmarks().then(() => flushPendingFlies())
}, 800)
window.setTimeout(() => flushPendingFlies(), 2500)
}
function onMessage(ev: MessageEvent) {
......@@ -1106,15 +1242,27 @@ export function useRtengineBridge(opts: {
const data = (msg as { data?: unknown }).data ?? msg
const bag = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
// 场景书签已加载(scene-embed 上报)——就绪后不再反复 markReady
// 右键旋转枢轴:仅本地 HUD,禁绘以免盖住 video
const eventId =
(bag && typeof bag.eventId === 'string' && bag.eventId) ||
(typeof (msg as { eventId?: string }).eventId === 'string'
? (msg as { eventId: string }).eventId
: '') ||
(type === 'orbitPivot' ? 'orbitPivot' : '')
if (eventId === 'orbitPivot') {
suppressOrbitPivotHud()
return
}
// 场景书签已加载(scene-embed 上报)
if (
!readyNotified &&
bag &&
(bag.bookmarksReady === true ||
(Array.isArray(bag.bookmarks) && bag.bookmarks.length > 0) ||
(typeof bag.bookmarkCount === 'number' && bag.bookmarkCount > 0))
) {
markReady()
flushPendingFlies()
}
if (
......@@ -1227,6 +1375,8 @@ export function useRtengineBridge(opts: {
setSolarTime,
setWeather,
setLighting,
setSceneConfig,
ensureAutoExposure,
applyDayNight,
captureEnvironmentLighting,
restoreInitialEnvironment,
......
......@@ -10,7 +10,6 @@ import {
MODE_MARKER_SCALE_X,
MODE_MARKER_SCALE_Y,
MODE_MARKER_SCALE_Z,
MODE_POIS,
fmtNum,
poisForMode,
type ModePoiDef,
......@@ -235,7 +234,7 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
}
}
/** 只卸我们跟踪过的点;不做 listMarkers 全表扫描(易误伤刚打上的点) */
/** 只卸当前分类跟踪过的点;勿扫 MODE_POIS 全表(会打出大量空 dc 的 removeMarker 报错) */
function removeActive() {
const ids = activeIds.slice()
activeIds = []
......@@ -246,19 +245,17 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
/* ignore */
}
}
for (const def of MODE_POIS) {
try {
bridge.removeMarker(poiMarkerId(def))
} catch {
/* ignore */
}
}
}
/** @param replace true=先卸再加(仅分类切换);false=引擎已有则只改文字,绝不 updateMarker(会闪没) */
function paint(markers: RtengineMarker[], replace = false) {
const engineIds = new Set((bridge.listMarkers?.() || []).map((x) => x.id))
const known = new Set([...activeIds, ...engineIds])
/**
* @param replace true=先卸再加(仅分类切换)
* @param readd true=跨域飞镜后强制再 add(listMarkers 读不到时不能只靠 activeIds 装活)
*/
function paint(markers: RtengineMarker[], replace = false, readd = false) {
const listed = bridge.listMarkers?.() || []
const engineIds = new Set(listed.map((x) => x.id))
// 跨域 iframe 时 listMarkers 恒为空;若仍信 activeIds,飞镜清点后永远不会再 add
const blind = listed.length === 0
for (const m of markers) {
if (replace) {
try {
......@@ -270,7 +267,9 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
bridge.setMarkerVisible(m.id, true)
continue
}
if (known.has(m.id)) {
const aliveInEngine = engineIds.has(m.id)
const assumeAlive = !blind && !readd && (aliveInEngine || activeIds.includes(m.id))
if (assumeAlive) {
if (m.text != null && bridge.updateMarkerText) {
bridge.updateMarkerText(m.id, String(m.text))
}
......@@ -308,17 +307,17 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
if (!enabled.value || !bridge.ready.value) return
if (mode.value !== expectedMode) return
const markers = markersForMode(expectedMode, realtime.snapshot)
// 分类切换后补打一次,避免被引擎异步/并发 sync 卸掉后没加回
paint(markers, true)
// 跨域无法确认引擎是否还留着点 → 强制再 add
paint(markers, false, true)
lastStructSig = structSigOf(markers)
lastTextSig = textSigOf(markers)
lastMode = expectedMode
activeIds = markers.map((m) => m.id)
}, 180)
}, 400)
}
/**
* @param force 补打/恢复(飞镜后):只 add,不先 remove
* @param force 补打/恢复(飞镜后):只 add,不先 remove;且跳过「结构未变」短路
* @param rebuild 分类切换:先 remove 再 add
*/
async function sync(opts: { force?: boolean; rebuild?: boolean } = {}) {
......@@ -333,14 +332,23 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
let removedPendingPaint = false
try {
const current = mode.value
const modeChanged = current !== lastMode
// lastMode=='' 视为首屏,不能当「分类切换」去卸点
const firstPaint = lastMode === ''
const modeChanged = !firstPaint && current !== lastMode
const snap = realtime.snapshot
const markers = markersForMode(current, snap)
const structSig = structSigOf(markers)
const textSig = textSigOf(markers)
// 同分类结构未变:只刷文案;force 且引擎里已有点时不要再 update/add(会闪)
if (!rebuild && !modeChanged && structSig === lastStructSig && activeIds.length) {
// 同分类结构未变:只刷文案。force 必须继续走 paint(飞镜后点已被引擎清掉)
if (
!force &&
!rebuild &&
!modeChanged &&
!firstPaint &&
structSig === lastStructSig &&
activeIds.length
) {
if (textSig !== lastTextSig) {
refreshTexts(markers)
lastTextSig = textSig
......@@ -349,7 +357,7 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
return
}
// 分类切换:卸旧再打
// 真正的分类切换才卸旧再打;首屏 / 补打只 add
if (rebuild || modeChanged) {
removeActive()
removedPendingPaint = true
......@@ -359,8 +367,9 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
removedPendingPaint = false
scheduleReinforce(current)
} else {
// 结构变化或首次:有则只改文字、无则 add,避免 updateMarker 闪没
paint(markers, false)
// force/首屏:跨域 blind 下强制 readd
paint(markers, false, force || firstPaint)
if (firstPaint || force) scheduleReinforce(current)
}
lastStructSig = structSig
lastTextSig = textSig
......
......@@ -83,6 +83,8 @@ export default {
kpiCold: 'Cold chain OK',
kpiEnv: 'Rooms OK',
kpiAlarm: 'Active alarms',
kpiTodayEnergy: 'Today energy',
kpiTodayEnergyHint: '3 meters total kWh',
gatewayOk: 'up',
gatewayDown: 'down',
gatewayHint: '{online}/{total} · GW {gw}',
......@@ -118,9 +120,12 @@ export default {
interlockOk: 'Interlock OK',
pressureWatch: 'Pressure watch',
pressureEmpty: 'Pressure history accumulating',
energyTitle: 'Energy & green · 24h',
energyTitle: 'Power meters · live',
timeseries: 'Timeseries',
powerTotal: 'Power',
powerMetersBadge: '3 meters',
powerTotal: 'Today total',
todayShort: 'Today',
meterReading: 'Register',
water: 'Water',
cooling: 'Cooling',
energyEmpty: 'Energy history accumulating',
......
......@@ -83,6 +83,8 @@ export default {
kpiCold: 'Cadeia de frio OK',
kpiEnv: 'Salas OK',
kpiAlarm: 'Alarmes ativos',
kpiTodayEnergy: 'Energia do dia',
kpiTodayEnergyHint: '3 medidores kWh',
gatewayOk: 'ok',
gatewayDown: 'falha',
gatewayHint: '{online}/{total} · GW {gw}',
......@@ -118,9 +120,12 @@ export default {
interlockOk: 'Interbloqueio OK',
pressureWatch: 'Pressão a vigiar',
pressureEmpty: 'Histórico de pressão a acumular',
energyTitle: 'Energia e sustentabilidade · 24h',
energyTitle: 'Medidores · tempo real',
timeseries: 'Séries',
powerTotal: 'Eletricidade',
powerMetersBadge: '3 medidores',
powerTotal: 'Total do dia',
todayShort: 'Hoje',
meterReading: 'Registo',
water: 'Água',
cooling: 'Frio',
energyEmpty: 'Histórico de energia a acumular',
......
......@@ -83,6 +83,8 @@ export default {
kpiCold: '冷链正常',
kpiEnv: '环境合格区',
kpiAlarm: '当前告警',
kpiTodayEnergy: '当日能耗',
kpiTodayEnergyHint: '三表合计 kWh',
gatewayOk: '通',
gatewayDown: '断',
gatewayHint: '{online}/{total} · 网关{gw}',
......@@ -118,9 +120,12 @@ export default {
interlockOk: '联锁正常',
pressureWatch: '压差关注',
pressureEmpty: '压差历史积累中',
energyTitle: '能耗与绿色 · 24h',
energyTitle: '电能统计 · 实时',
timeseries: '时序',
powerTotal: '总用电',
powerMetersBadge: '三表',
powerTotal: '当日总用电',
todayShort: '今日',
meterReading: '表底',
water: '用水',
cooling: '冷量',
energyEmpty: '能耗历史积累中',
......
......@@ -83,6 +83,8 @@ export default {
kpiCold: '冷鏈正常',
kpiEnv: '環境合格區',
kpiAlarm: '當前告警',
kpiTodayEnergy: '當日能耗',
kpiTodayEnergyHint: '三表合計 kWh',
gatewayOk: '通',
gatewayDown: '斷',
gatewayHint: '{online}/{total} · 閘道{gw}',
......@@ -118,9 +120,12 @@ export default {
interlockOk: '聯鎖正常',
pressureWatch: '壓差關注',
pressureEmpty: '壓差歷史累積中',
energyTitle: '能耗與綠色 · 24h',
energyTitle: '電能統計 · 即時',
timeseries: '時序',
powerTotal: '總用電',
powerMetersBadge: '三表',
powerTotal: '當日總用電',
todayShort: '今日',
meterReading: '表底',
water: '用水',
cooling: '冷量',
energyEmpty: '能耗歷史累積中',
......
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
......@@ -20,6 +20,7 @@ const { time, date } = useClock()
const { place, temp, text: weatherText, humidity } = useMacauWeather()
const { style } = useStageScale()
const { title: screenTitle } = useScreenTitle()
const sceneRef = ref<InstanceType<typeof SceneViewport> | null>(null)
onMounted(() => {
realtime.connect()
......@@ -33,6 +34,10 @@ function goAdmin() {
if (auth.isLoggedIn) router.push('/admin')
else router.push({ path: '/login', query: { redirect: '/admin' } })
}
function onHeaderHome() {
sceneRef.value?.flyToHome?.(true)
}
</script>
<template>
......@@ -42,7 +47,7 @@ function goAdmin() {
<!-- 底层:RTEngine / 三维场景铺满 -->
<div class="scene-layer">
<SceneViewport layout="fill" />
<SceneViewport ref="sceneRef" layout="fill" />
</div>
<!-- 四周渐变遮罩:压暗边缘,中间留白给三维 -->
......@@ -55,7 +60,7 @@ function goAdmin() {
<!-- 上层:标题 / 导航 / 业务面板悬浮 -->
<div class="ui-layer">
<ScreenHeader :title="screenTitle">
<ScreenHeader :title="screenTitle" @home="onHeaderHome">
<template #left>
<div class="weather-wrap">
<span class="place">{{ place }}</span>
......
......@@ -27,6 +27,7 @@ const empty: RealtimeSnapshot = {
powerTrendPct: 0,
waterTrendPct: 0,
coolingTrendPct: 0,
powerMeters: [],
},
access: { doors: [], todayPassCount: 0, recentEvents: [] },
online: { devicesOnline: 0, devicesTotal: 0, gatewayOk: false },
......
......@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
import type { ScreenMode } from '@/data/screenModePois'
export const useScreenModeStore = defineStore('screenMode', () => {
/** 大屏默认:压差分类;视角仍用「全景总览」书签 */
/** 大屏默认:压差分类;视角用「主视图」书签 */
const mode = ref<ScreenMode>('pressure')
function setMode(next: ScreenMode) {
......
......@@ -122,12 +122,21 @@ export interface RealtimeSnapshot {
neLights?: NeLightSnapshot[]
sensors?: SensorSnapshot[]
metering: {
/** 当日三块总表耗电合计 kWh */
powerKwh: number
waterM3: number
coolingKwh: number
powerTrendPct: number
waterTrendPct: number
coolingTrendPct: number
powerMeters?: {
key: 'pau201' | 'e_power' | 'n_power'
name: string
deviceId: string
energyKwh: number | null
powerKw: number | null
todayKwh: number
}[]
}
access: {
doors: DoorSnapshot[]
......
......@@ -71,3 +71,34 @@ export interface BillingReport {
}
rows: DailyBill[]
}
/** PAU201 / E电 / N电 当天·当月电耗 */
export interface PowerEnergyMeterUsage {
key: 'pau201' | 'e_power' | 'n_power'
name: string
deviceId: string
usageKwh: number
energyKwh: number | null
powerKw: number | null
}
export interface PowerEnergyStats {
date: string
month: string
today: {
totalKwh: number
meters: PowerEnergyMeterUsage[]
}
monthPeriod: {
totalKwh: number
meters: PowerEnergyMeterUsage[]
}
trend30d: {
date: string
label: string
totalKwh: number
pau201: number
ePower: number
nPower: number
}[]
}
......@@ -4,19 +4,25 @@ import { useAuthStore } from '@/stores/auth'
import { useRealtimeStore } from '@/stores/realtime'
import { api } from '@/api/http'
import CostLineChart from '@/components/charts/CostLineChart.vue'
import type { BillingReport, BillingStats, TariffSettings } from '@/types/billing'
import type {
BillingReport,
BillingStats,
PowerEnergyStats,
TariffSettings,
} from '@/types/billing'
type Tab = 'meter' | 'stats' | 'tariff' | 'report'
const auth = useAuthStore()
const realtime = useRealtimeStore()
const tab = ref<Tab>('stats')
const tab = ref<Tab>('meter')
const loading = ref(false)
const saving = ref(false)
const message = ref('')
const stats = ref<BillingStats | null>(null)
const powerEnergy = ref<PowerEnergyStats | null>(null)
const tariff = reactive<TariffSettings>({
powerPeak: 0,
powerFlat: 0,
......@@ -36,20 +42,43 @@ const reportTo = ref('')
const report = ref<BillingReport | null>(null)
const power30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.powerKwh })),
(powerEnergy.value?.trend30d || stats.value?.usage30d || []).map((d) => ({
label: d.label,
value: 'totalKwh' in d ? d.totalKwh : d.powerKwh,
})),
)
const powerMetersLive = computed(() => realtime.snapshot.metering.powerMeters || [])
const water30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.waterM3 })),
)
const cooling30d = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label, value: d.coolingKwh })),
)
const cost7d = computed(() =>
(stats.value?.trend7d || []).map((d) => ({ label: d.date, value: d.total })),
)
const cost12m = computed(() =>
(stats.value?.trend12m || []).map((d) => ({ label: d.label, value: d.total })),
/** 近 7 日用电量(不按费用) */
const energy7d = computed(() => {
const rows = stats.value?.usage30d || []
return rows.slice(-7).map((d) => ({ label: d.label || d.date, value: d.powerKwh }))
})
/** 近 30 日用电量 */
const energy30dUsage = computed(() =>
(stats.value?.usage30d || []).map((d) => ({ label: d.label || d.date, value: d.powerKwh })),
)
const todayPowerKwh = computed(() => {
const t = stats.value?.today
if (!t) return powerEnergy.value?.today.totalKwh ?? realtime.snapshot.metering.powerKwh
return +(t.powerPeakKwh + t.powerFlatKwh + t.powerValleyKwh).toFixed(1)
})
/** 用量构成(按 kWh/m³ 归一到占比,不展示元) */
const usageByKind = computed(() => {
const kinds = stats.value?.byKind || []
if (!kinds.length) return []
const total = kinds.reduce((s, k) => s + (k.usage || 0), 0) || 1
return kinds.map((k) => ({
...k,
pct: Math.round(((k.usage || 0) / total) * 1000) / 10,
}))
})
function pad(n: number) {
return n < 10 ? `0${n}` : `${n}`
......@@ -74,6 +103,12 @@ async function loadStats() {
stats.value = await api<BillingStats>('/api/billing/stats', { token: auth.token })
}
async function loadPowerEnergy() {
powerEnergy.value = await api<PowerEnergyStats>('/api/billing/power-energy', {
token: auth.token,
})
}
async function loadTariff() {
const data = await api<TariffSettings>('/api/billing/tariff', { token: auth.token })
Object.assign(tariff, data)
......@@ -93,7 +128,7 @@ async function refreshAll() {
try {
if (!reportFrom.value) reportFrom.value = daysAgo(29)
if (!reportTo.value) reportTo.value = todayStr()
await Promise.all([loadStats(), loadTariff(), loadReport()])
await Promise.all([loadStats(), loadPowerEnergy(), loadTariff(), loadReport()])
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败'
} finally {
......@@ -191,7 +226,7 @@ onUnmounted(() => {
<div class="page-fill billing-page admin-billing">
<div class="billing-tabs">
<button type="button" class="tab" :class="{ active: tab === 'meter' }" @click="tab = 'meter'">计量概览</button>
<button type="button" class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">费用统计</button>
<button type="button" class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">能耗统计</button>
<button type="button" class="tab" :class="{ active: tab === 'tariff' }" @click="tab = 'tariff'">计费设置</button>
<button type="button" class="tab" :class="{ active: tab === 'report' }" @click="tab = 'report'">费用报表</button>
<div class="tab-spacer" />
......@@ -199,30 +234,84 @@ onUnmounted(() => {
<span v-if="message" class="msg">{{ message }}</span>
</div>
<!-- 计量概览 -->
<!-- 计量概览:PAU201 / E电 / N电 -->
<template v-if="tab === 'meter'">
<div class="metric-grid three">
<section class="panel metric-card">
<div class="m-label">今日用电 (kWh)</div>
<div class="m-value">{{ realtime.snapshot.metering.powerKwh.toFixed(1) }}</div>
<div class="m-sub">较昨日 {{ realtime.snapshot.metering.powerTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(realtime.snapshot.metering.powerTrendPct) }}%</div>
<div class="metric-grid two">
<section class="panel metric-card tone-amber">
<div class="m-label">今日总用电 (kWh)</div>
<div class="m-value">
{{ (powerEnergy?.today.totalKwh ?? realtime.snapshot.metering.powerKwh).toFixed(1) }}
</div>
<div class="m-sub">
PAU201 + E电 + N电 · {{ powerEnergy?.date || '今日' }}
</div>
</section>
<section class="panel metric-card tone-cyan">
<div class="m-label">本月总用电 (kWh)</div>
<div class="m-value">{{ (powerEnergy?.monthPeriod.totalKwh ?? 0).toFixed(1) }}</div>
<div class="m-sub">{{ powerEnergy?.month || '本月' }} 累计</div>
</section>
</div>
<div class="metric-grid three meter-split">
<section
v-for="m in powerEnergy?.today.meters || powerMetersLive"
:key="m.key || m.name"
class="panel metric-card"
>
<div class="m-label">{{ m.name }} · 今日</div>
<div class="m-value">
{{ ('usageKwh' in m ? m.usageKwh : m.todayKwh).toFixed(1) }}
<small>kWh</small>
</div>
<div class="m-sub">
本月
{{
(
powerEnergy?.monthPeriod.meters.find((x) => x.key === m.key)?.usageKwh ?? 0
).toFixed(1)
}}
kWh
<template v-if="m.energyKwh != null">
· 表计 {{ m.energyKwh.toFixed(1) }}
</template>
<template v-if="m.powerKw != null"> · {{ m.powerKw.toFixed(2) }} kW</template>
</div>
</section>
</div>
<div class="metric-grid three">
<section class="panel metric-card">
<div class="m-label">今日用水 (m³)</div>
<div class="m-value">{{ realtime.snapshot.metering.waterM3.toFixed(1) }}</div>
<div class="m-sub">较昨日 {{ realtime.snapshot.metering.waterTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(realtime.snapshot.metering.waterTrendPct) }}%</div>
<div class="m-sub">
较昨日 {{ realtime.snapshot.metering.waterTrendPct > 0 ? '↑' : '↓' }}
{{ Math.abs(realtime.snapshot.metering.waterTrendPct) }}%
</div>
</section>
<section class="panel metric-card">
<div class="m-label">冷冻水冷量 (kWh)</div>
<div class="m-value">{{ realtime.snapshot.metering.coolingKwh }}</div>
<div class="m-sub">较昨日 ↑ {{ realtime.snapshot.metering.coolingTrendPct }}%</div>
</section>
<section class="panel metric-card">
<div class="m-label">三表瞬时功率合计</div>
<div class="m-value">
{{
powerMetersLive
.reduce((s, m) => s + (m.powerKw ?? 0), 0)
.toFixed(1)
}}
<small>kW</small>
</div>
<div class="m-sub">PAU201 / E电 / N电</div>
</section>
</div>
<div class="usage-charts">
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近一个月用电趋势</h3></div>
<div class="panel-title"><div class="vbar" /><h3>近一个月用电趋势(三表合计)</h3></div>
<div class="badge">kWh</div>
</div>
<CostLineChart :points="power30d" color="#fbbf24" unit="kWh" :height="220" />
......@@ -244,63 +333,92 @@ onUnmounted(() => {
</div>
</template>
<!-- 费用统计 -->
<template v-else-if="tab === 'stats' && stats">
<!-- 能耗统计(不统计费用) -->
<template v-else-if="tab === 'stats'">
<div class="metric-grid four">
<section class="panel metric-card tone-amber">
<div class="m-label">今日费用 (元)</div>
<div class="m-value">{{ money(stats.today.total) }}</div>
<div class="m-sub">含服务费与税</div>
<div class="m-label">今日用电 (kWh)</div>
<div class="m-value">{{ todayPowerKwh.toFixed(1) }}</div>
<div class="m-sub">PAU201 + E电 + N电</div>
</section>
<section class="panel metric-card tone-cyan">
<div class="m-label">本月累计 (元)</div>
<div class="m-value">{{ money(stats.month.total) }}</div>
<div class="m-sub">已计 {{ stats.month.days }} 天</div>
<div class="m-label">本月用电 (kWh)</div>
<div class="m-value">
{{ (powerEnergy?.monthPeriod.totalKwh ?? stats?.month.powerKwh ?? 0).toFixed(1) }}
</div>
<div class="m-sub">已计 {{ stats?.month.days ?? '—' }} 天</div>
</section>
<section class="panel metric-card tone-green">
<div class="m-label">本月电费 (元)</div>
<div class="m-value">{{ money(stats.month.powerCost) }}</div>
<div class="m-sub">用电 {{ stats.month.powerKwh }} kWh</div>
<div class="m-label">今日用水 (m³)</div>
<div class="m-value">
{{ (stats?.today.waterM3 ?? realtime.snapshot.metering.waterM3).toFixed(1) }}
</div>
<div class="m-sub">本月 {{ stats?.month.waterM3 ?? '—' }} m³</div>
</section>
<section class="panel metric-card tone-violet">
<div class="m-label">本月水+冷 (元)</div>
<div class="m-value">{{ money(stats.month.waterCost + stats.month.coolingCost) }}</div>
<div class="m-sub">水 {{ stats.month.waterM3 }} m³ · 冷 {{ stats.month.coolingKwh }} kWh</div>
<div class="m-label">今日冷量 (kWh)</div>
<div class="m-value">
{{ (stats?.today.coolingKwh ?? realtime.snapshot.metering.coolingKwh).toFixed(0) }}
</div>
<div class="m-sub">本月 {{ stats?.month.coolingKwh ?? '—' }} kWh</div>
</section>
</div>
<div class="metric-grid three meter-split">
<section
v-for="m in powerMetersLive.length ? powerMetersLive : powerEnergy?.today.meters || []"
:key="m.key || m.name"
class="panel metric-card"
>
<div class="m-label">{{ m.name }} · 实时</div>
<div class="m-value">
<template v-if="m.powerKw != null">{{ m.powerKw.toFixed(2) }} <small>kW</small></template>
<template v-else>—</template>
</div>
<div class="m-sub">
今日
{{
('todayKwh' in m ? m.todayKwh : 'usageKwh' in m ? m.usageKwh : 0).toFixed(1)
}}
kWh
<template v-if="m.energyKwh != null"> · 表底 {{ m.energyKwh.toFixed(1) }}</template>
</div>
</section>
</div>
<div class="split">
<section class="panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>本月费用构成</h3></div>
<div class="panel-title"><div class="vbar" /><h3>本月用量构成</h3></div>
</div>
<div class="kind-list">
<div v-for="k in stats.byKind" :key="k.kind" class="kind-row" :class="k.kind">
<div v-for="k in usageByKind" :key="k.kind" class="kind-row" :class="k.kind">
<div class="kind-meta">
<span class="kind-name">{{ k.label }}</span>
<span class="kind-name">{{ k.kind === 'power' ? '用电' : k.kind === 'water' ? '用水' : '冷量' }}</span>
<span class="kind-usage">{{ k.usage }} {{ k.unit }}</span>
</div>
<div class="kind-bar"><i :style="{ width: `${k.pct}%` }" /></div>
<div class="kind-cost">¥ {{ money(k.cost) }} <em>{{ k.pct }}%</em></div>
<div class="kind-cost"><em>{{ k.pct }}%</em></div>
</div>
<div v-if="!usageByKind.length" class="m-sub" style="padding: 12px">暂无用量数据</div>
</div>
</section>
<section class="panel chart-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近 7 日费用趋势</h3></div>
<div class="badge">元</div>
<div class="panel-title"><div class="vbar" /><h3>近 7 日用电趋势</h3></div>
<div class="badge">kWh</div>
</div>
<CostLineChart :points="cost7d" color="#7ad8ff" unit="元" :height="160" />
<CostLineChart :points="energy7d" color="#fbbf24" unit="kWh" :height="160" />
</section>
</div>
<section class="panel chart-panel month-trend">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>近 12 个月费用趋势</h3></div>
<div class="badge">元</div>
<div class="panel-title"><div class="vbar" /><h3>近 30 日用电趋势</h3></div>
<div class="badge">kWh</div>
</div>
<CostLineChart :points="cost12m" color="#a78bfa" unit="元" :height="170" />
<CostLineChart :points="energy30dUsage" color="#67e8f9" unit="kWh" :height="170" />
</section>
</template>
......@@ -464,8 +582,11 @@ onUnmounted(() => {
.tab-spacer { flex: 1; }
.msg { font-size: 12px; color: var(--cyan-lt); }
.metric-grid { display: grid; gap: 12px; flex: 0 0 auto; }
.metric-grid.two { grid-template-columns: repeat(2, 1fr); }
.metric-grid.three { grid-template-columns: repeat(3, 1fr); }
.metric-grid.four { grid-template-columns: repeat(4, 1fr); }
.metric-grid.meter-split { margin-top: 0; }
.m-value small { font-size: 14px; margin-left: 4px; color: var(--label); font-weight: 500; }
.metric-card { padding: 16px 18px; }
.metric-card .m-label { font-size: 12px; color: var(--label); margin-bottom: 8px; }
.metric-card .m-value {
......
......@@ -24,7 +24,7 @@ const kpiItems = computed<KpiFlipItem[]>(() => {
{ 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' },
{ key: 'energy', label: t('overview.kpiTodayEnergy'), value: '—', tone: 'power', icon: 'kpi-power' },
]
}
const rate = d.deviceTotal ? ((d.deviceOnline / d.deviceTotal) * 100).toFixed(0) : '0'
......@@ -55,16 +55,19 @@ const kpiItems = computed<KpiFlipItem[]>(() => {
icon: 'kpi-access',
},
{
key: 'alarm',
label: t('overview.kpiAlarm'),
value: String(d.alarmOpen),
hint: d.alarmOpen ? t('overview.dutyFollow') : t('overview.noOpenAlarm'),
tone: d.alarmOpen ? 'power' : 'device',
key: 'energy',
label: t('overview.kpiTodayEnergy'),
value: d.powerKwh.toFixed(1),
hint: t('overview.kpiTodayEnergyHint'),
tone: 'power',
icon: 'kpi-power',
},
]
})
/** 右下角电能:三块电表实时读数 */
const powerMeters = computed(() => snap.value.metering?.powerMeters || [])
const chartText = '#9ec8dc'
function statusLabel(key: string | undefined, fallback: string) {
......@@ -337,13 +340,29 @@ const energyDigits = computed(() => digits.value)
<PanelBox :title="t('overview.energyTitle')">
<template #extra>
<span class="badge">{{ t('overview.timeseries') }}</span>
<span class="badge">{{ t('overview.powerMetersBadge') }}</span>
</template>
<div class="panel-stack">
<div class="energy-digits">
<div class="energy-digits energy-digits--meters">
<div
v-for="m in powerMeters"
:key="m.key"
class="ed"
>
<span>{{ m.name }}</span>
<b>
<template v-if="m.powerKw != null">{{ m.powerKw.toFixed(2) }} <small>kW</small></template>
<template v-else>—</template>
</b>
<em>{{ t('overview.todayShort') }} {{ m.todayKwh.toFixed(1) }} kWh</em>
</div>
<template v-if="!powerMeters.length">
<div class="ed">
<span>{{ t('overview.powerTotal') }}</span>
<b>{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }} <small>kWh</small></b>
<b>
{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }}
<small>kWh</small>
</b>
</div>
<div class="ed">
<span>{{ t('overview.water') }}</span>
......@@ -353,6 +372,7 @@ const energyDigits = computed(() => digits.value)
<span>{{ t('overview.cooling') }}</span>
<b>{{ energyDigits ? energyDigits.coolingKwh.toFixed(0) : '—' }} <small>kWh</small></b>
</div>
</template>
</div>
<EChart
class="chart-main"
......@@ -498,29 +518,48 @@ const energyDigits = computed(() => digits.value)
gap: 6px;
flex-shrink: 0;
}
.energy-digits--meters {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ed {
padding: 8px;
padding: 6px 8px;
border-radius: 4px;
background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.14);
min-width: 0;
}
.ed span {
display: block;
font-size: 10px;
color: var(--panel-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ed b {
display: block;
margin-top: 4px;
font-size: 16px;
margin-top: 2px;
font-size: 15px;
font-family: var(--font-num);
color: #fff;
white-space: nowrap;
}
.ed small {
font-size: 10px;
color: rgba(200, 230, 245, 0.65);
font-weight: 500;
}
.ed em {
display: block;
margin-top: 2px;
font-style: normal;
font-size: 10px;
color: rgba(158, 210, 232, 0.78);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.screen-err {
position: absolute;
left: 50%;
......
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