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)
}
......
......@@ -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[]
......
......@@ -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">
<img class="banner" :src="headerBar" alt="" />
<h1 :class="{ 'is-cjk': cjkTitle }">{{ title || t('screen.defaultTitle') }}</h1>
<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',
......
......@@ -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
}[]
}
......@@ -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,22 +340,39 @@ 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="ed">
<span>{{ t('overview.powerTotal') }}</span>
<b>{{ energyDigits ? energyDigits.powerKwh.toFixed(1) : '—' }} <small>kWh</small></b>
</div>
<div class="ed">
<span>{{ t('overview.water') }}</span>
<b>{{ energyDigits ? energyDigits.waterM3.toFixed(1) : '—' }} <small>m³</small></b>
</div>
<div class="ed">
<span>{{ t('overview.cooling') }}</span>
<b>{{ energyDigits ? energyDigits.coolingKwh.toFixed(0) : '—' }} <small>kWh</small></b>
<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>
</div>
<div class="ed">
<span>{{ t('overview.water') }}</span>
<b>{{ energyDigits ? energyDigits.waterM3.toFixed(1) : '—' }} <small>m³</small></b>
</div>
<div class="ed">
<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