Commit e1a7ff5a by James Yuan

feat: 后台设备/点位/设施支持过滤,采集网关改从网关 API 拉取

列表增加关键词与条件筛选;采集网关优先读 Windows 网关 /health.modbus,不可达时回退本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 00d85ce0
...@@ -508,6 +508,135 @@ export async function listGateways(): Promise<Gateway[]> { ...@@ -508,6 +508,135 @@ export async function listGateways(): Promise<Gateway[]> {
return rows.map((r) => mapGw(r as Record<string, unknown>)) return rows.map((r) => mapGw(r as Record<string, unknown>))
} }
export type CollectGateway = Gateway & {
/** 来自网关 /health 的连通性 */
ok?: boolean | null
latencyMs?: number | null
/** gateway=Windows 网关 API;local=平台本地库回退 */
source: 'gateway' | 'local'
}
/**
* 采集网关列表:优先 Windows 网关 /health 的 modbus 主机;
* 网关不可达时回退本地 gateways 表(历史硬编码种子)。
*/
export async function listCollectGateways(): Promise<{
gateways: CollectGateway[]
source: 'gateway' | 'local'
gatewayReachable: boolean
message?: string
}> {
const { fetchGatewayModbusHosts } = await import('../gateway/client.js')
const live = await fetchGatewayModbusHosts()
const local = await listGateways()
const localMap = new Map(local.map((g) => [g.id, g]))
if (live.ok && live.hosts.length) {
const t = now()
const gateways: CollectGateway[] = []
for (const h of live.hosts) {
const prev = localMap.get(h.id)
const name = (h.name && h.name.trim()) || prev?.name || h.id
const enabled = h.enabled ?? prev?.enabled ?? true
const row: CollectGateway = {
id: h.id,
name,
kind: 'modbus_host',
host: h.host,
port: h.port,
enabled,
meta: { ...(prev?.meta || {}), fromGatewayApi: true },
lastOkAt: h.ok ? t : prev?.lastOkAt ?? null,
createdAt: prev?.createdAt || t,
updatedAt: t,
ok: h.ok ?? null,
latencyMs: h.latencyMs ?? null,
source: 'gateway',
}
gateways.push(row)
// 把网关 API 的 host/port 回写本地,避免点位绑定主机漂移
try {
await upsertGatewayRow({
id: row.id,
name: row.name,
host: row.host,
port: row.port,
enabled: row.enabled,
meta: row.meta,
lastOkAt: row.lastOkAt,
})
} catch (e) {
console.warn('[devices] upsert gateway from API failed', h.id, e)
}
}
return { gateways, source: 'gateway', gatewayReachable: true }
}
return {
gateways: local.map((g) => ({
...g,
ok: null,
latencyMs: null,
source: 'local' as const,
})),
source: 'local',
gatewayReachable: live.ok,
message: live.message || (live.ok ? '网关未配置 Modbus 主机' : '网关不可达,已显示本地缓存'),
}
}
async function upsertGatewayRow(input: {
id: string
name: string
host: string
port: number
enabled: boolean
meta: Record<string, unknown>
lastOkAt: string | null
}) {
const t = now()
if (!isPgEnabled()) {
const i = mem.gateways.findIndex((g) => g.id === input.id)
const row: Gateway = {
id: input.id,
name: input.name,
kind: 'modbus_host',
host: input.host,
port: input.port,
enabled: input.enabled,
meta: input.meta,
lastOkAt: input.lastOkAt,
createdAt: i >= 0 ? mem.gateways[i].createdAt : t,
updatedAt: t,
}
if (i >= 0) mem.gateways[i] = row
else mem.gateways.push(row)
saveFile()
return
}
await query(
`INSERT INTO gateways (id, name, kind, host, port, enabled, meta, last_ok_at, created_at, updated_at)
VALUES ($1,$2,'modbus_host',$3,$4,$5,$6::jsonb,$7,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
host = EXCLUDED.host,
port = EXCLUDED.port,
enabled = EXCLUDED.enabled,
meta = EXCLUDED.meta,
last_ok_at = COALESCE(EXCLUDED.last_ok_at, gateways.last_ok_at),
updated_at = NOW()`,
[
input.id,
input.name,
input.host,
input.port,
input.enabled,
JSON.stringify(input.meta ?? {}),
input.lastOkAt,
],
)
}
export async function updateGateway( export async function updateGateway(
id: string, id: string,
patch: Partial<Pick<Gateway, 'name' | 'host' | 'port' | 'enabled' | 'meta'>>, patch: Partial<Pick<Gateway, 'name' | 'host' | 'port' | 'enabled' | 'meta'>>,
......
...@@ -84,6 +84,65 @@ export async function fetchGatewayHealth(): Promise<{ ...@@ -84,6 +84,65 @@ export async function fetchGatewayHealth(): Promise<{
} }
} }
/** 网关 /health 中的 Modbus 主机(采集网关列表来源) */
export type GatewayModbusHost = {
id: string
name?: string
host: string
port: number
enabled?: boolean
ok?: boolean
latencyMs?: number
}
export function parseGatewayModbusHosts(raw: unknown): GatewayModbusHost[] {
if (!raw || typeof raw !== 'object') return []
const modbus = (raw as { modbus?: unknown }).modbus
if (!Array.isArray(modbus)) return []
const out: GatewayModbusHost[] = []
for (const item of modbus) {
if (!item || typeof item !== 'object') continue
const m = item as Record<string, unknown>
const host = String(m.host || '').trim()
const port = Number(m.port)
if (!host || !Number.isFinite(port) || port <= 0) continue
const id = String(m.id || '').trim() || `mb-${host}-${port}`
out.push({
id,
name: m.name != null ? String(m.name) : undefined,
host,
port,
enabled: m.enabled === undefined ? true : Boolean(m.enabled),
ok: m.ok === undefined ? undefined : Boolean(m.ok),
latencyMs: m.latencyMs == null ? undefined : Number(m.latencyMs),
})
}
return out
}
/** 从 Windows 网关 API 拉取当前配置的采集主机 */
export async function fetchGatewayModbusHosts(): Promise<{
ok: boolean
hosts: GatewayModbusHost[]
message?: string
raw?: unknown
}> {
const health = await fetchGatewayHealth()
if (!health.ok) {
return { ok: false, hosts: [], message: health.message || '网关不可达', raw: health.raw }
}
const hosts = parseGatewayModbusHosts(health.raw)
if (!hosts.length) {
return {
ok: true,
hosts: [],
message: '网关在线但未配置 Modbus 主机',
raw: health.raw,
}
}
return { ok: true, hosts, raw: health.raw }
}
export async function fetchGatewaySnapshot(): Promise<unknown | null> { export async function fetchGatewaySnapshot(): Promise<unknown | null> {
const base = resolveGatewayBase() const base = resolveGatewayBase()
if (!base) return null if (!base) return null
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
exportPoints, exportPoints,
getPointsVersion, getPointsVersion,
importPointsCsv, importPointsCsv,
listCollectGateways,
listDevices, listDevices,
listGateways, listGateways,
listPoints, listPoints,
...@@ -24,8 +25,27 @@ import { inferPointRw } from '../devices/pointRw.js' ...@@ -24,8 +25,27 @@ import { inferPointRw } from '../devices/pointRw.js'
export const devicesRouter = Router() export const devicesRouter = Router()
/** 采集网关列表:优先 Windows 网关 API(/health.modbus),失败回退本地库 */
devicesRouter.get('/gateways', authRequired, async (_req, res) => { devicesRouter.get('/gateways', authRequired, async (_req, res) => {
res.json({ gateways: await listGateways(), pointsVersion: await getPointsVersion() }) try {
const live = await listCollectGateways()
res.json({
gateways: live.gateways,
pointsVersion: await getPointsVersion(),
source: live.source,
gatewayReachable: live.gatewayReachable,
message: live.message,
})
} catch (e) {
console.error('[devices] list gateways failed', e)
res.json({
gateways: await listGateways(),
pointsVersion: await getPointsVersion(),
source: 'local',
gatewayReachable: false,
message: e instanceof Error ? e.message : '拉取网关列表失败',
})
}
}) })
devicesRouter.get('/gateways/health', authRequired, async (_req, res) => { devicesRouter.get('/gateways/health', authRequired, async (_req, res) => {
......
...@@ -100,6 +100,8 @@ const error = ref('') ...@@ -100,6 +100,8 @@ const error = ref('')
const sensors = ref<Sensor[]>([]) const sensors = ref<Sensor[]>([])
const points = ref<DevicePoint[]>([]) const points = ref<DevicePoint[]>([])
const filterCategory = ref('') const filterCategory = ref('')
const filterQ = ref('')
const filterEnabled = ref('')
const showEdit = ref(false) const showEdit = ref(false)
const editingId = ref<string | null>(null) const editingId = ref<string | null>(null)
const pickOpen = ref(false) const pickOpen = ref(false)
...@@ -165,10 +167,31 @@ const form = reactive({ ...@@ -165,10 +167,31 @@ const form = reactive({
} as Record<string, string>, } as Record<string, string>,
}) })
const filtered = computed(() => { function matchKeyword(parts: Array<string | number | null | undefined>, q: string) {
if (!filterCategory.value) return sensors.value const key = q.trim().toLowerCase()
return sensors.value.filter((s) => s.category === filterCategory.value) if (!key) return true
}) return parts.some((p) => String(p ?? '').toLowerCase().includes(key))
}
const filtered = computed(() =>
sensors.value.filter((s) => {
if (filterCategory.value && s.category !== filterCategory.value) return false
if (filterEnabled.value === '1' && !s.enabled) return false
if (filterEnabled.value === '0' && s.enabled) return false
const bindText = (s.points || [])
.map((b) => `${b.pointName || ''} ${b.itemId || ''} ${b.role}`)
.join(' ')
if (
!matchKeyword(
[s.name, s.code, s.category, CATEGORY_LABEL[s.category], bindText, s.id],
filterQ.value,
)
) {
return false
}
return true
}),
)
const pointOptions = computed(() => const pointOptions = computed(() =>
points.value points.value
...@@ -382,10 +405,6 @@ onMounted(load) ...@@ -382,10 +405,6 @@ onMounted(load)
<div class="panel-head"> <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="head-actions"> <div class="head-actions">
<select v-model="filterCategory" class="filter">
<option value="">全部分类</option>
<option v-for="(label, key) in CATEGORY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button> <button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
<button v-if="auth.isAdmin" class="btn" type="button" @click="openCreate">新建设施</button> <button v-if="auth.isAdmin" class="btn" type="button" @click="openCreate">新建设施</button>
</div> </div>
...@@ -394,6 +413,30 @@ onMounted(load) ...@@ -394,6 +413,30 @@ onMounted(load)
<p class="desc"> <p class="desc">
设施含压差、照明 N/E 电、冰箱、通风柜、摄像头、门禁(后四类与交底表对齐)。摄像头/门禁无采集点绑定,可通过「三维打点」调整坐标与图标。 设施含压差、照明 N/E 电、冰箱、通风柜、摄像头、门禁(后四类与交底表对齐)。摄像头/门禁无采集点绑定,可通过「三维打点」调整坐标与图标。
</p> </p>
<div class="filters">
<div class="field field-grow">
<label>搜索</label>
<input v-model="filterQ" type="search" class="filter" placeholder="名称 / 编码 / 绑定点位…" />
</div>
<div class="field">
<label>分类</label>
<select v-model="filterCategory" class="filter">
<option value="">全部</option>
<option v-for="(label, key) in CATEGORY_LABEL" :key="key" :value="key">{{ label }}</option>
</select>
</div>
<div class="field">
<label>启用</label>
<select v-model="filterEnabled" class="filter">
<option value="">全部</option>
<option value="1">已启用</option>
<option value="0">已停用</option>
</select>
</div>
<span class="filter-hint">当前 {{ filtered.length }} / {{ sensors.length }}</span>
</div>
<p v-if="message" class="msg ok">{{ message }}</p> <p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error && !showEdit" class="msg err">{{ error }}</p> <p v-if="error && !showEdit" class="msg err">{{ error }}</p>
...@@ -457,9 +500,12 @@ onMounted(load) ...@@ -457,9 +500,12 @@ onMounted(load)
<span v-if="!auth.isAdmin" class="muted">—</span> <span v-if="!auth.isAdmin" class="muted">—</span>
</td> </td>
</tr> </tr>
<tr v-if="!filtered.length"> <tr v-if="!sensors.length">
<td colspan="7" class="muted">暂无设施</td> <td colspan="7" class="muted">暂无设施</td>
</tr> </tr>
<tr v-else-if="!filtered.length">
<td colspan="7" class="muted">无匹配设施,请调整过滤条件</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
...@@ -569,14 +615,41 @@ onMounted(load) ...@@ -569,14 +615,41 @@ onMounted(load)
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
margin-bottom: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 120px;
}
.field-grow {
flex: 1 1 220px;
min-width: 180px;
}
.field label {
font-size: 12px;
opacity: 0.75;
}
.filter { .filter {
min-width: 120px; min-width: 120px;
width: 100%;
border-radius: 2px; border-radius: 2px;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
background: rgba(6, 12, 20, 0.55); background: rgba(6, 12, 20, 0.55);
color: #eaf4ff; color: #eaf4ff;
padding: 6px 10px; padding: 6px 10px;
} }
.filter-hint {
font-size: 12px;
color: var(--panel-muted);
margin-bottom: 6px;
}
.desc { .desc {
margin: 0 0 10px; margin: 0 0 10px;
font-size: 12px; font-size: 12px;
......
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