Commit 466251ce by James Yuan

fix: 抑制大屏闪烁并保证设备列表本地落库

轮询与图表更新不再整屏闪动;PG 空库时从 devices.json 灌入设备主数据,网关离线不影响后台列表。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 9c9efc33
...@@ -120,17 +120,45 @@ function defaultGateways(): Gateway[] { ...@@ -120,17 +120,45 @@ function defaultGateways(): Gateway[] {
] ]
} }
/**
* 种子优先级(与网关是否在线无关,主数据必须落在平台本地):
* 1) 现场协议 CSV 目录
* 2) 已有 server/data/devices.json(PG 空库时从文件灌入)
* 3) 仅网关占位(无设备/点位)
*/
function seedBundle(): DevicesBundle { function seedBundle(): DevicesBundle {
const dir = resolveProtocolDir() const dir = resolveProtocolDir()
if (dir) { if (dir) {
try { try {
return buildBundleFromProtocolDir(dir) const fromCsv = buildBundleFromProtocolDir(dir)
if (fromCsv.devices.length || fromCsv.points.length) {
console.log(
`[devices] seed source: protocol CSV → ${fromCsv.devices.length} devices, ${fromCsv.points.length} points`,
)
return fromCsv
}
console.warn('[devices] protocol CSV 目录无有效设备/点位,尝试 devices.json')
} catch (e) { } catch (e) {
console.warn('[devices] protocol CSV seed failed, fallback empty gateways:', e) console.warn('[devices] protocol CSV seed failed, try devices.json:', e)
} }
} else { } else {
console.warn('[devices] DEVICE_POINTS_DIR 未找到,仅写入网关占位(无设备/点位)') console.warn('[devices] DEVICE_POINTS_DIR 未找到,尝试 devices.json')
}
const file = loadFile()
if (file && (file.devices.length || file.points.length)) {
console.log(
`[devices] seed source: devices.json → ${file.devices.length} devices, ${file.points.length} points`,
)
return {
gateways: file.gateways?.length ? file.gateways : defaultGateways(),
devices: file.devices || [],
points: file.points || [],
pointsVersion: file.pointsVersion || 1,
}
} }
console.warn('[devices] 无 CSV/json 种子,仅写入网关占位(无设备/点位)')
return { gateways: defaultGateways(), devices: [], points: [], pointsVersion: 1 } return { gateways: defaultGateways(), devices: [], points: [], pointsVersion: 1 }
} }
...@@ -300,6 +328,24 @@ async function writeSeedPg(seed: DevicesBundle) { ...@@ -300,6 +328,24 @@ async function writeSeedPg(seed: DevicesBundle) {
) )
} }
/** PG 主数据备份到 devices.json,避免仅存库时容器重建丢种源 */
async function backupPgToDevicesFile() {
try {
const [gateways, devices, points, pointsVersion] = await Promise.all([
listGateways(),
listDevices(),
listPoints(),
getPointsVersion(),
])
if (!devices.length && !points.length) return
const bundle: DevicesBundle = { gateways, devices, points, pointsVersion }
fs.mkdirSync(config.dataDir, { recursive: true })
fs.writeFileSync(config.devicesFile, JSON.stringify(bundle, null, 2), 'utf8')
} catch (e) {
console.warn('[devices] backup to devices.json failed:', e instanceof Error ? e.message : e)
}
}
export async function ensureDevicesSeed() { export async function ensureDevicesSeed() {
const force = process.env.DEVICES_RESEED === '1' const force = process.env.DEVICES_RESEED === '1'
const protocolDir = resolveProtocolDir() const protocolDir = resolveProtocolDir()
...@@ -321,16 +367,44 @@ export async function ensureDevicesSeed() { ...@@ -321,16 +367,44 @@ export async function ensureDevicesSeed() {
return Number(rows[0]?.c || 0) === 0 && devN > 0 return Number(rows[0]?.c || 0) === 0 && devN > 0
})())) })()))
if (gwN === 0 || force || (protocolDir && stub)) { // PG 已有完整设备主数据:只做字段回填,绝不因网关离线而清空
if (gwN > 0) { if (!force && gwN > 0 && devN > 0 && !stub) {
console.log('[devices] replacing stub/fridge-only seed with protocol CSV…') await backfillAlarmLimitsPgOnce()
await clearDevicesDataPg() await backfillPointRwPg()
await backupPgToDevicesFile()
return
} }
// PG 空库 / 仅冰箱占位 / 强制重种:从 CSV 或 devices.json 灌入
// 含「有网关但 0 设备」——Docker 里常因找不到协议目录而只种了网关占位
const needSeed = force || gwN === 0 || stub || (gwN > 0 && devN === 0)
if (needSeed) {
const seed = seedBundle() const seed = seedBundle()
if (!seed.devices.length && !force) {
if (devN > 0) {
console.warn(
'[devices] seed 无设备,拒绝用空数据覆盖 PostgreSQL 现有设备(保留本地库)',
)
await backfillAlarmLimitsPgOnce()
await backfillPointRwPg()
return
}
// 库也是空的且没有种源:写网关占位即可,但不要误报成「已同步网关设备」
console.warn(
'[devices] PostgreSQL 设备表为空且无 CSV/devices.json 种源;列表与 Windows 网关无关,请导入点位或挂载 DEVICE_POINTS_DIR',
)
}
if ((gwN > 0 || devN > 0 || ptN > 0) && (seed.devices.length || force)) {
console.log(
`[devices] replacing PG seed (gw=${gwN} dev=${devN} pt=${ptN}, stub=${stub}, csv=${Boolean(protocolDir)})…`,
)
await clearDevicesDataPg()
}
await writeSeedPg(seed) await writeSeedPg(seed)
console.log( console.log(
`[devices] seeded ${seed.gateways.length} gateways, ${seed.devices.length} devices, ${seed.points.length} points`, `[devices] seeded ${seed.gateways.length} gateways, ${seed.devices.length} devices, ${seed.points.length} points`,
) )
if (seed.devices.length) await backupPgToDevicesFile()
} else { } else {
await backfillAlarmLimitsPgOnce() await backfillAlarmLimitsPgOnce()
await backfillPointRwPg() await backfillPointRwPg()
......
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, shallowRef, watch } from 'vue'
import { use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts' import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts'
...@@ -62,29 +62,59 @@ const style = computed(() => ({ ...@@ -62,29 +62,59 @@ const style = computed(() => ({
minHeight: '80px', minHeight: '80px',
})) }))
const isEmpty = computed(() => { function seriesEmpty(option: ECOption | null | undefined) {
const series = props.option?.series const series = option?.series
if (!series) return true if (!series) return true
const list = Array.isArray(series) ? series : [series] const list = Array.isArray(series) ? series : [series]
return list.every((s) => { return list.every((s) => {
const data = (s as { data?: unknown[] }).data const data = (s as { data?: unknown[] }).data
return !data || data.length === 0 return !data || data.length === 0
}) })
}) }
const isEmpty = computed(() => seriesEmpty(props.option))
/** 空数据时仍给 ECharts 合法 option,避免 v-if 反复挂载触发 __vnode 错误 */ /** 空数据时仍给 ECharts 合法 option,避免 v-if 反复挂载触发 __vnode 错误 */
const EMPTY_OPTION: ECOption = { const EMPTY_OPTION: ECOption = {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: false,
series: [], series: [],
} }
const chartOption = computed(() => (isEmpty.value ? EMPTY_OPTION : props.option)) /** 保留上一份有效 option,避免轮询空窗把图清成空白再闪回来 */
const lastGood = shallowRef<ECOption>(EMPTY_OPTION)
watch(
() => props.option,
(opt) => {
if (!seriesEmpty(opt)) lastGood.value = opt
},
{ immediate: true },
)
const chartOption = computed(() => {
const base = isEmpty.value ? lastGood.value || EMPTY_OPTION : props.option
return {
...base,
animation: false,
animationDuration: 0,
animationDurationUpdate: 0,
} as ECOption
})
const updateOpts = { notMerge: false, lazyUpdate: true, silent: true }
</script> </script>
<template> <template>
<div class="echart-wrap" :style="style"> <div class="echart-wrap" :style="style">
<VChart class="echart" :option="chartOption" autoresize :manual-update="false" /> <VChart
<div v-if="isEmpty" class="echart-empty">{{ emptyText }}</div> class="echart"
:option="chartOption"
:update-options="updateOpts"
autoresize
:manual-update="false"
/>
<div v-if="isEmpty && seriesEmpty(lastGood)" class="echart-empty">{{ emptyText }}</div>
</div> </div>
</template> </template>
......
...@@ -404,11 +404,25 @@ const bridge = useRtengineBridge({ ...@@ -404,11 +404,25 @@ const bridge = useRtengineBridge({
onMouseClick: onMarkerClick, onMouseClick: onMarkerClick,
}) })
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() { async function loadCameras() {
try { try {
const res = await api<{ devices: VideoDevice[] }>('/api/video/devices') const res = await api<{ devices: VideoDevice[] }>('/api/video/devices')
cameras.value = res.devices || [] const next = res.devices || []
markersSynced = false const changed = cameraSceneSig(cameras.value) !== cameraSceneSig(next)
cameras.value = next
// 仅场景打点变化时才允许再次 addMarkers,避免重复写入打光照
if (changed) markersSynced = false
} catch { } catch {
/* 大屏允许匿名;失败则无点位 */ /* 大屏允许匿名;失败则无点位 */
} }
...@@ -472,11 +486,16 @@ function onViewerLoad() { ...@@ -472,11 +486,16 @@ function onViewerLoad() {
}) })
} }
/** 平面图主区域 + 实时测点(按 roomId 关联协议房间) */ /**
* 平面图主区域。
* fill+云渲染:不订阅 rooms 时序,避免 WS 每秒刷新带动 iframe 所在组件重绘闪一下。
* 嵌入/本地模型:仍合并实时温湿度与告警。
*/
const areaList = computed(() => { const areaList = computed(() => {
const live = realtime.snapshot.rooms const immersive = props.layout === 'fill' && useCloudFrame.value
const live = immersive ? null : realtime.snapshot.rooms
return SCREEN_AREAS.map((def) => { return SCREEN_AREAS.map((def) => {
const hit = live.find((r) => r.id === def.roomId) const hit = live?.find((r) => r.id === def.roomId)
return { return {
id: def.id, id: def.id,
name: def.name, name: def.name,
...@@ -698,6 +717,10 @@ onBeforeUnmount(() => { ...@@ -698,6 +717,10 @@ onBeforeUnmount(() => {
.scene, .ue-frame { .scene, .ue-frame {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0; position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
background: #000; background: #000;
/* 独立合成层,减轻侧栏/图表重绘时连带闪一下 */
transform: translateZ(0);
contain: strict;
content-visibility: auto;
} }
.vp-topbar, .vp-tip { .vp-topbar, .vp-tip {
position: absolute; z-index: 5; position: absolute; z-index: 5;
......
...@@ -415,8 +415,11 @@ export function useRtengineBridge(opts: { ...@@ -415,8 +415,11 @@ export function useRtengineBridge(opts: {
send('clearMarkers', {}) send('clearMarkers', {})
} }
let presetsShown = false
/** 显示场景内预设 POI / Marker(合并为一次,减少场景刷新/光照闪变) */ /** 显示场景内预设 POI / Marker(合并为一次,减少场景刷新/光照闪变) */
function showScenePresets() { function showScenePresets() {
if (presetsShown) return
presetsShown = true
send('setMarkerVisible', { visible: true }) send('setMarkerVisible', { visible: true })
send('showPOIs', { visible: true }) send('showPOIs', { visible: true })
} }
...@@ -424,7 +427,8 @@ export function useRtengineBridge(opts: { ...@@ -424,7 +427,8 @@ export function useRtengineBridge(opts: {
let readyNotified = false let readyNotified = false
function markReady() { function markReady() {
if (readyNotified) { if (readyNotified) {
void ensureBookmarks().then(() => flushPendingFlies()) // 已就绪:只冲未飞完的视点,不再反复 ensureBookmarks
if (pendingFlies.length) flushPendingFlies()
return return
} }
readyNotified = true readyNotified = true
...@@ -445,15 +449,15 @@ export function useRtengineBridge(opts: { ...@@ -445,15 +449,15 @@ export function useRtengineBridge(opts: {
const data = (msg as { data?: unknown }).data ?? msg const data = (msg as { data?: unknown }).data ?? msg
const bag = data && typeof data === 'object' ? (data as Record<string, unknown>) : null const bag = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
// 场景书签已加载(scene-embed 上报) // 场景书签已加载(scene-embed 上报)——就绪后不再反复 markReady
if ( if (
!readyNotified &&
bag && bag &&
(bag.bookmarksReady === true || (bag.bookmarksReady === true ||
(Array.isArray(bag.bookmarks) && bag.bookmarks.length > 0) || (Array.isArray(bag.bookmarks) && bag.bookmarks.length > 0) ||
(typeof bag.bookmarkCount === 'number' && bag.bookmarkCount > 0)) (typeof bag.bookmarkCount === 'number' && bag.bookmarkCount > 0))
) { ) {
markReady() markReady()
flushPendingFlies()
} }
if ( if (
......
...@@ -16,6 +16,36 @@ const emptyDigits: ScreenDashboard['digits'] = { ...@@ -16,6 +16,36 @@ const emptyDigits: ScreenDashboard['digits'] = {
coolingKwh: 0, coolingKwh: 0,
} }
/** 忽略每次响应里的 ts,内容未变则不触发面板/图表重绘 */
function dashboardFingerprint(d: ScreenDashboard): string {
const curveTip = (curves: ScreenDashboard['curves'] | undefined) => {
if (!curves) return ''
return Object.entries(curves)
.map(([k, list]) => {
const arr = list || []
return `${k}:${arr.length}:${arr
.map((s) => {
const last = s.points?.[s.points.length - 1]
return `${s.id}:${s.points?.length || 0}:${last?.t ?? ''}:${last?.v ?? ''}`
})
.join(',')}`
})
.join('|')
}
return JSON.stringify({
digits: d.digits,
statusPie: d.statusPie,
categoryPie: d.categoryPie,
envBubbles: d.envBubbles,
pauCards: d.pauCards,
pressureBars: d.pressureBars,
fridgeBars: d.fridgeBars,
curves: curveTip(d.curves),
alarmN: d.snapshot?.pointAlarms?.length ?? 0,
snapTs: d.snapshot?.ts ?? 0,
})
}
export function useScreenDashboard(pollMs = 5000) { export function useScreenDashboard(pollMs = 5000) {
/** shallow:整包替换,避免对 curves/snapshot 深度代理导致渲染卡死 */ /** shallow:整包替换,避免对 curves/snapshot 深度代理导致渲染卡死 */
const data = shallowRef<ScreenDashboard | null>(null) const data = shallowRef<ScreenDashboard | null>(null)
...@@ -23,13 +53,18 @@ export function useScreenDashboard(pollMs = 5000) { ...@@ -23,13 +53,18 @@ export function useScreenDashboard(pollMs = 5000) {
const error = ref('') const error = ref('')
let timer: number | undefined let timer: number | undefined
let inFlight = false let inFlight = false
let lastFp = ''
async function refresh() { async function refresh() {
if (inFlight) return if (inFlight) return
inFlight = true inFlight = true
try { try {
const next = await api<ScreenDashboard>('/api/realtime/screen') const next = await api<ScreenDashboard>('/api/realtime/screen')
const fp = dashboardFingerprint(next)
if (fp !== lastFp) {
lastFp = fp
data.value = next data.value = next
}
error.value = '' error.value = ''
} catch (e) { } catch (e) {
if (!data.value) { if (!data.value) {
......
...@@ -43,6 +43,8 @@ export function multiLineOption( ...@@ -43,6 +43,8 @@ export function multiLineOption(
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: false,
animationDurationUpdate: 0,
legend: { legend: {
type: 'scroll', type: 'scroll',
bottom: 0, bottom: 0,
...@@ -79,6 +81,7 @@ export function multiLineOption( ...@@ -79,6 +81,7 @@ export function multiLineOption(
type: 'line' as const, type: 'line' as const,
showSymbol: false, showSymbol: false,
smooth: true, smooth: true,
animation: false,
lineStyle: { width: 1.5 }, lineStyle: { width: 1.5 },
itemStyle: { color: PALETTE[i % PALETTE.length] }, itemStyle: { color: PALETTE[i % PALETTE.length] },
data: xs.map((t) => { data: xs.map((t) => {
......
...@@ -260,7 +260,8 @@ async function loadAll() { ...@@ -260,7 +260,8 @@ async function loadAll() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const [gw, dev, pts, health] = await Promise.all([ // 设备/点位来自平台本地库;网关健康检查失败不得拖垮整表
const [gwR, devR, ptsR, healthR] = await Promise.allSettled([
api<{ gateways: Gateway[]; pointsVersion: number }>('/api/devices/gateways', { api<{ gateways: Gateway[]; pointsVersion: number }>('/api/devices/gateways', {
token: auth.token, token: auth.token,
}), }),
...@@ -270,14 +271,31 @@ async function loadAll() { ...@@ -270,14 +271,31 @@ async function loadAll() {
}), }),
api<{ ok: boolean; message?: string; raw?: unknown }>('/api/devices/gateways/health', { api<{ ok: boolean; message?: string; raw?: unknown }>('/api/devices/gateways/health', {
token: auth.token, token: auth.token,
}).catch(() => ({ ok: false, message: '健康检查失败' })), }),
]) ])
gateways.value = gw.gateways if (gwR.status === 'fulfilled') {
pointsVersion.value = pts.pointsVersion || gw.pointsVersion gateways.value = gwR.value.gateways
devices.value = dev.devices if (!importForm.gatewayId && gwR.value.gateways[0]) {
points.value = pts.points importForm.gatewayId = gwR.value.gateways[0].id
gatewayHealth.value = health }
if (!importForm.gatewayId && gw.gateways[0]) importForm.gatewayId = gw.gateways[0].id if (gwR.value.pointsVersion) pointsVersion.value = gwR.value.pointsVersion
}
if (devR.status === 'fulfilled') devices.value = devR.value.devices
if (ptsR.status === 'fulfilled') {
points.value = ptsR.value.points
pointsVersion.value = ptsR.value.pointsVersion || pointsVersion.value
}
if (healthR.status === 'fulfilled') gatewayHealth.value = healthR.value
else gatewayHealth.value = { ok: false, message: 'Windows 网关未连接(不影响本地设备列表)' }
const failed = [gwR, devR, ptsR].filter((r) => r.status === 'rejected')
if (failed.length === 3) {
const reason = (failed[0] as PromiseRejectedResult).reason
error.value = reason instanceof Error ? reason.message : '加载失败'
} else if (devR.status === 'rejected') {
const reason = (devR as PromiseRejectedResult).reason
error.value = reason instanceof Error ? reason.message : '设备列表加载失败'
}
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '加载失败' error.value = e instanceof Error ? e.message : '加载失败'
} finally { } finally {
......
...@@ -74,6 +74,8 @@ const envBubbleOption = computed<ECOption>(() => { ...@@ -74,6 +74,8 @@ const envBubbleOption = computed<ECOption>(() => {
) )
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: false,
animationDurationUpdate: 0,
grid: { left: 36, right: 12, top: 28, bottom: 28 }, grid: { left: 36, right: 12, top: 28, bottom: 28 },
tooltip: { tooltip: {
...compactTooltipBase, ...compactTooltipBase,
...@@ -163,12 +165,15 @@ const statusPieOption = computed<ECOption>(() => { ...@@ -163,12 +165,15 @@ const statusPieOption = computed<ECOption>(() => {
} }
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: false,
animationDurationUpdate: 0,
tooltip: pieTooltip(), tooltip: pieTooltip(),
series: [ series: [
{ {
type: 'pie', type: 'pie',
radius: ['42%', '68%'], radius: ['42%', '68%'],
center: ['50%', '52%'], center: ['50%', '52%'],
animation: false,
label: { color: chartText, fontSize: 10, formatter: '{b}\n{c}' }, label: { color: chartText, fontSize: 10, formatter: '{b}\n{c}' },
data: pie.map((p) => ({ data: pie.map((p) => ({
name: p.name, name: p.name,
...@@ -184,6 +189,8 @@ const categoryPieOption = computed<ECOption>(() => { ...@@ -184,6 +189,8 @@ const categoryPieOption = computed<ECOption>(() => {
const pie = data.value?.categoryPie || [] const pie = data.value?.categoryPie || []
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: false,
animationDurationUpdate: 0,
tooltip: pieTooltip(), tooltip: pieTooltip(),
series: [ series: [
{ {
...@@ -191,6 +198,7 @@ const categoryPieOption = computed<ECOption>(() => { ...@@ -191,6 +198,7 @@ const categoryPieOption = computed<ECOption>(() => {
radius: ['36%', '62%'], radius: ['36%', '62%'],
center: ['50%', '52%'], center: ['50%', '52%'],
roseType: 'radius', roseType: 'radius',
animation: false,
label: { color: chartText, fontSize: 9, formatter: '{b}' }, label: { color: chartText, fontSize: 9, formatter: '{b}' },
data: pie.map((p) => ({ name: p.name, value: p.value })), data: pie.map((p) => ({ name: p.name, value: p.value })),
}, },
......
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