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[]> {
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(
id: string,
patch: Partial<Pick<Gateway, 'name' | 'host' | 'port' | 'enabled' | 'meta'>>,
......
......@@ -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> {
const base = resolveGatewayBase()
if (!base) return null
......
......@@ -10,6 +10,7 @@ import {
exportPoints,
getPointsVersion,
importPointsCsv,
listCollectGateways,
listDevices,
listGateways,
listPoints,
......@@ -24,8 +25,27 @@ import { inferPointRw } from '../devices/pointRw.js'
export const devicesRouter = Router()
/** 采集网关列表:优先 Windows 网关 API(/health.modbus),失败回退本地库 */
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) => {
......
......@@ -10,6 +10,9 @@ interface Gateway {
host: string
port: number
enabled: boolean
ok?: boolean | null
latencyMs?: number | null
source?: 'gateway' | 'local'
}
interface Device {
......@@ -82,6 +85,8 @@ const gateways = ref<Gateway[]>([])
const devices = ref<Device[]>([])
const points = ref<DevicePoint[]>([])
const pointsVersion = ref(1)
const gatewaysSource = ref<'gateway' | 'local'>('local')
const gatewaysMessage = ref('')
const gatewayHealth = ref<{ ok: boolean; message?: string; raw?: unknown } | null>(null)
const selectedDevice = ref<Device | null>(null)
const returnToDeviceView = ref(false)
......@@ -92,6 +97,35 @@ let viewRefreshTimer: number | undefined
const filterGatewayId = ref('')
const filterDeviceId = ref('')
/** 设备表过滤 */
const filterDeviceQ = ref('')
const filterDeviceGatewayId = ref('')
const filterDeviceCategory = ref('')
const filterDeviceEnabled = ref('')
/** 点位表关键词 / 读写 */
const filterPointQ = ref('')
const filterPointRw = ref('')
const DEVICE_CATEGORY_LABEL: Record<string, string> = {
pau: '机组',
vav: '通风柜',
room: '房间屏',
fridge: '冰箱',
meter_power: '电表',
meter_water: '水表/流量计',
meter_cooling: '能量计',
light: '照明',
door: '门禁',
camera: '摄像头',
nvr: 'NVR',
other: '其它',
}
function matchKeyword(parts: Array<string | number | null | undefined>, q: string) {
const key = q.trim().toLowerCase()
if (!key) return true
return parts.some((p) => String(p ?? '').toLowerCase().includes(key))
}
const importForm = reactive({
gatewayId: 'mb-104',
......@@ -129,6 +163,20 @@ const filteredPoints = computed(() =>
points.value.filter((p) => {
if (filterGatewayId.value && p.gatewayId !== filterGatewayId.value) return false
if (filterDeviceId.value && p.deviceId !== filterDeviceId.value) return false
if (filterPointRw.value) {
const rw = p.rw || 'r'
if (filterPointRw.value === 'r' && rw !== 'r') return false
if (filterPointRw.value === 'w' && rw !== 'w' && rw !== 'rw') return false
if (filterPointRw.value === 'rw' && rw !== 'rw') return false
}
if (
!matchKeyword(
[p.name, p.itemId, p.modbusAddress, p.dataType, deviceName(p.deviceId), p.id],
filterPointQ.value,
)
) {
return false
}
return true
}),
)
......@@ -140,6 +188,36 @@ const devicesWithPointCount = computed(() =>
})),
)
const deviceCategories = computed(() => {
const set = new Set(devices.value.map((d) => d.category).filter(Boolean))
return [...set].sort().map((value) => ({
value,
label: DEVICE_CATEGORY_LABEL[value] || value,
}))
})
const filteredDevices = computed(() =>
devicesWithPointCount.value.filter((d) => {
if (filterDeviceGatewayId.value && d.gatewayId !== filterDeviceGatewayId.value) return false
if (filterDeviceCategory.value && d.category !== filterDeviceCategory.value) return false
if (filterDeviceEnabled.value === '1' && !d.enabled) return false
if (filterDeviceEnabled.value === '0' && d.enabled) return false
if (
!matchKeyword(
[d.name, d.code, d.category, d.bindUi, d.status, d.meta?.sourceCsv, d.id],
filterDeviceQ.value,
)
) {
return false
}
return true
}),
)
function categoryLabel(cat: string) {
return DEVICE_CATEGORY_LABEL[cat] || cat || '—'
}
const selectedDevicePoints = computed(() => {
if (!selectedDevice.value) return []
return points.value
......@@ -262,7 +340,13 @@ async function loadAll() {
try {
// 设备/点位来自平台本地库;网关健康检查失败不得拖垮整表
const [gwR, devR, ptsR, healthR] = await Promise.allSettled([
api<{ gateways: Gateway[]; pointsVersion: number }>('/api/devices/gateways', {
api<{
gateways: Gateway[]
pointsVersion: number
source?: 'gateway' | 'local'
gatewayReachable?: boolean
message?: string
}>('/api/devices/gateways', {
token: auth.token,
}),
api<{ devices: Device[] }>('/api/devices', { token: auth.token }),
......@@ -275,6 +359,8 @@ async function loadAll() {
])
if (gwR.status === 'fulfilled') {
gateways.value = gwR.value.gateways
gatewaysSource.value = gwR.value.source || 'local'
gatewaysMessage.value = gwR.value.message || ''
if (!importForm.gatewayId && gwR.value.gateways[0]) {
importForm.gatewayId = gwR.value.gateways[0].id
}
......@@ -537,6 +623,13 @@ function gatewayName(id: string) {
return gateways.value.find((g) => g.id === id)?.name || id
}
function gatewayChipStatus(g: Gateway) {
if (g.ok === true) return { cls: 'on', text: '通' }
if (g.ok === false) return { cls: 'off', text: '断' }
if (g.enabled) return { cls: 'on', text: '启用' }
return { cls: 'off', text: '停用' }
}
function deviceName(id: string | null) {
if (!id) return '—'
return devices.value.find((d) => d.id === id)?.name || id
......@@ -589,17 +682,31 @@ onUnmounted(stopViewRefresh)
</div>
<div class="gw-strip">
<div class="gw-strip-title">采集网关({{ gateways.length }})</div>
<div class="gw-strip-title">
采集网关({{ gateways.length }})
<span class="gw-source" :class="gatewaysSource === 'gateway' ? 'live' : 'cache'">
{{ gatewaysSource === 'gateway' ? '来自网关 API' : '本地缓存' }}
</span>
</div>
<div class="gw-chips">
<span v-for="g in gateways" :key="g.id" class="gw-chip" :title="`${g.id} · ${g.host}:${g.port}`">
<span
v-for="g in gateways"
:key="g.id"
class="gw-chip"
:title="`${g.id} · ${g.host}:${g.port}${g.latencyMs != null ? ` · ${g.latencyMs}ms` : ''}`"
>
<b>{{ g.name }}</b>
<span class="mono">{{ g.host }}:{{ g.port }}</span>
<i :class="g.enabled ? 'on' : 'off'">{{ g.enabled ? '启用' : '停用' }}</i>
<i :class="gatewayChipStatus(g).cls">{{ gatewayChipStatus(g).text }}</i>
</span>
<span v-if="!gateways.length" class="muted">暂无网关主机</span>
</div>
<p class="hint gw-hint">
由 Windows 网关连接上述主机采集;本页维护设备、点位并下发。
{{
gatewaysSource === 'gateway'
? '列表由 Windows 网关 /health 实时下发;本页维护设备、点位并回写平台。'
: gatewaysMessage || '网关未连接,显示平台本地缓存;连通后将自动切换为网关 API 列表。'
}}
</p>
</div>
......@@ -610,7 +717,7 @@ onUnmounted(stopViewRefresh)
type="button"
@click="tab = 'devices'"
>
设备({{ devices.length }})
设备({{ filteredDevices.length }}/{{ devices.length }})
</button>
<button
class="btn"
......@@ -618,11 +725,49 @@ onUnmounted(stopViewRefresh)
type="button"
@click="tab = 'points'"
>
点位({{ points.length }})
点位({{ filteredPoints.length }}/{{ points.length }})
</button>
</div>
<div v-if="tab === 'points'" class="filters">
<div v-if="tab === 'devices'" class="filters">
<div class="field field-grow">
<label>搜索</label>
<input v-model="filterDeviceQ" type="search" placeholder="名称 / 编码 / CSV…" />
</div>
<div class="field">
<label>网关</label>
<select v-model="filterDeviceGatewayId">
<option value="">全部</option>
<option v-for="g in gateways" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div class="field">
<label>类别</label>
<select v-model="filterDeviceCategory">
<option value="">全部</option>
<option v-for="c in deviceCategories" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</div>
<div class="field">
<label>启用</label>
<select v-model="filterDeviceEnabled">
<option value="">全部</option>
<option value="1">已启用</option>
<option value="0">已停用</option>
</select>
</div>
<span class="filter-hint">当前 {{ filteredDevices.length }} / {{ devices.length }}</span>
</div>
<div v-else class="filters">
<div class="field field-grow">
<label>搜索</label>
<input
v-model="filterPointQ"
type="search"
placeholder="名称 / ItemID / 地址 / 设备…"
/>
</div>
<div class="field">
<label>网关</label>
<select v-model="filterGatewayId">
......@@ -637,7 +782,16 @@ onUnmounted(stopViewRefresh)
<option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
</div>
<span class="filter-hint">当前 {{ filteredPoints.length }} 条</span>
<div class="field">
<label>读写</label>
<select v-model="filterPointRw">
<option value="">全部</option>
<option value="r">采集</option>
<option value="w">写控</option>
<option value="rw">读写</option>
</select>
</div>
<span class="filter-hint">当前 {{ filteredPoints.length }} / {{ points.length }}</span>
</div>
<p v-if="message" class="msg ok">{{ message }}</p>
......@@ -649,6 +803,7 @@ onUnmounted(stopViewRefresh)
<tr>
<th>名称</th>
<th>编码</th>
<th>类别</th>
<th>点位数</th>
<th>网关</th>
<th>来源 CSV</th>
......@@ -657,20 +812,24 @@ onUnmounted(stopViewRefresh)
</thead>
<tbody>
<tr
v-for="d in devicesWithPointCount"
v-for="d in filteredDevices"
:key="d.id"
class="row-click"
@click="openDeviceDetail(d)"
>
<td>{{ d.name }}</td>
<td class="mono">{{ d.code }}</td>
<td>{{ categoryLabel(d.category) }}</td>
<td>{{ d.pointCount }}</td>
<td>{{ d.gatewayId ? gatewayName(d.gatewayId) : '—' }}</td>
<td class="mono">{{ d.meta?.sourceCsv || '—' }}</td>
<td>{{ d.status }}</td>
</tr>
<tr v-if="!devices.length">
<td colspan="6" class="empty">暂无设备,点击右上角「新增设备」或导入点位 CSV</td>
<td colspan="7" class="empty">暂无设备,点击右上角「新增设备」或导入点位 CSV</td>
</tr>
<tr v-else-if="!filteredDevices.length">
<td colspan="7" class="empty">无匹配设备,请调整过滤条件</td>
</tr>
</tbody>
</table>
......@@ -749,11 +908,14 @@ onUnmounted(stopViewRefresh)
<button class="btn ghost" type="button" @click="removePoint(p)">删</button>
</td>
</tr>
<tr v-if="!filteredPoints.length">
<tr v-if="!points.length">
<td :colspan="auth.isAdmin ? 12 : 10" class="empty">
暂无点位,点击右上角「导入 CSV」或「新增点位」
</td>
</tr>
<tr v-else-if="!filteredPoints.length">
<td :colspan="auth.isAdmin ? 12 : 10" class="empty">无匹配点位,请调整过滤条件</td>
</tr>
</tbody>
</table>
</div>
......@@ -1107,6 +1269,10 @@ onUnmounted(stopViewRefresh)
gap: 4px;
min-width: 140px;
}
.field-grow {
flex: 1 1 220px;
min-width: 180px;
}
.field label {
font-size: 12px;
opacity: 0.75;
......@@ -1214,10 +1380,27 @@ onUnmounted(stopViewRefresh)
background: rgba(6, 16, 28, 0.45);
}
.gw-strip-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--panel-muted);
margin-bottom: 8px;
}
.gw-source {
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid rgba(0, 247, 255, 0.25);
}
.gw-source.live {
color: var(--ok, #4dffb5);
border-color: rgba(77, 255, 181, 0.35);
}
.gw-source.cache {
color: #ffcc88;
border-color: rgba(255, 180, 80, 0.35);
}
.gw-chips {
display: flex;
flex-wrap: wrap;
......
......@@ -100,6 +100,8 @@ const error = ref('')
const sensors = ref<Sensor[]>([])
const points = ref<DevicePoint[]>([])
const filterCategory = ref('')
const filterQ = ref('')
const filterEnabled = ref('')
const showEdit = ref(false)
const editingId = ref<string | null>(null)
const pickOpen = ref(false)
......@@ -165,10 +167,31 @@ const form = reactive({
} as Record<string, string>,
})
const filtered = computed(() => {
if (!filterCategory.value) return sensors.value
return sensors.value.filter((s) => s.category === filterCategory.value)
})
function matchKeyword(parts: Array<string | number | null | undefined>, q: string) {
const key = q.trim().toLowerCase()
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(() =>
points.value
......@@ -382,10 +405,6 @@ onMounted(load)
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>设施管理</h3></div>
<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 v-if="auth.isAdmin" class="btn" type="button" @click="openCreate">新建设施</button>
</div>
......@@ -394,6 +413,30 @@ onMounted(load)
<p class="desc">
设施含压差、照明 N/E 电、冰箱、通风柜、摄像头、门禁(后四类与交底表对齐)。摄像头/门禁无采集点绑定,可通过「三维打点」调整坐标与图标。
</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="error && !showEdit" class="msg err">{{ error }}</p>
......@@ -457,9 +500,12 @@ onMounted(load)
<span v-if="!auth.isAdmin" class="muted">—</span>
</td>
</tr>
<tr v-if="!filtered.length">
<tr v-if="!sensors.length">
<td colspan="7" class="muted">暂无设施</td>
</tr>
<tr v-else-if="!filtered.length">
<td colspan="7" class="muted">无匹配设施,请调整过滤条件</td>
</tr>
</tbody>
</table>
</div>
......@@ -569,14 +615,41 @@ onMounted(load)
align-items: center;
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 {
min-width: 120px;
width: 100%;
border-radius: 2px;
border: 1px solid var(--panel-border);
background: rgba(6, 12, 20, 0.55);
color: #eaf4ff;
padding: 6px 10px;
}
.filter-hint {
font-size: 12px;
color: var(--panel-muted);
margin-bottom: 6px;
}
.desc {
margin: 0 0 10px;
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