|
|
@@ -0,0 +1,249 @@
|
|
|
+<template>
|
|
|
+ <div class="device-live page-container">
|
|
|
+ <div class="header">
|
|
|
+ <a-page-header :title="device?.Name || '设备实时数据'" @back="router.back()">
|
|
|
+ <template #subtitle>
|
|
|
+ <a-space>
|
|
|
+ <a-tag v-if="device?.Status" :color="device.Status === 'NORMAL' ? 'green' : 'red'">
|
|
|
+ {{ device.Status }}
|
|
|
+ </a-tag>
|
|
|
+ <span>{{ device?.DeviceType }}</span>
|
|
|
+ </a-space>
|
|
|
+ </template>
|
|
|
+ <template #extra>
|
|
|
+ <a-button @click="fetchData">刷新</a-button>
|
|
|
+ </template>
|
|
|
+ </a-page-header>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <a-card :loading="loading" title="实体状态列表">
|
|
|
+ <a-table :data="entities" :pagination="false">
|
|
|
+ <template #columns>
|
|
|
+ <a-table-column title="实体名称">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <span v-if="record.display_name" style="font-weight: bold">{{ record.display_name }}</span>
|
|
|
+ <span v-else>{{ record.attributes?.friendly_name || record.entity_id }}</span>
|
|
|
+ <br/>
|
|
|
+ <span style="font-size: 12px; color: var(--color-text-3)">{{ record.entity_id }}</span>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="实时值" data-index="state">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <a-tag v-if="record.cleaned_value !== undefined" color="green" size="large">
|
|
|
+ {{ record.cleaned_value }} (TSDB)
|
|
|
+ </a-tag>
|
|
|
+ <a-tag v-else color="arcoblue" size="large">{{ record.state }}</a-tag>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="更新时间" data-index="last_updated">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <div v-if="record.cleaned_ts">
|
|
|
+ {{ formatTime(record.cleaned_ts) }}
|
|
|
+ </div>
|
|
|
+ <div v-else>
|
|
|
+ {{ formatTime(record.last_updated) }}
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ <a-table-column title="操作" :width="120">
|
|
|
+ <template #cell="{ record }">
|
|
|
+ <a-button
|
|
|
+ v-if="canToggle(record)"
|
|
|
+ type="primary"
|
|
|
+ size="small"
|
|
|
+ :status="record.state === 'on' ? 'warning' : 'success'"
|
|
|
+ :loading="actionLoading"
|
|
|
+ @click="handleToggle(record)"
|
|
|
+ >
|
|
|
+ <template #icon><icon-thunderbolt /></template>
|
|
|
+ {{ record.state === 'on' ? '关闭' : '开启' }}
|
|
|
+ </a-button>
|
|
|
+ </template>
|
|
|
+ </a-table-column>
|
|
|
+ </template>
|
|
|
+ </a-table>
|
|
|
+ </a-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, onMounted, onUnmounted } from 'vue';
|
|
|
+import { useRoute, useRouter } from 'vue-router';
|
|
|
+import { getDevice, getSourceDeviceEntities, callSourceService, getDeviceRealtime, type Device, type HAEntity } from '../../api/resource';
|
|
|
+import { Message } from '@arco-design/web-vue';
|
|
|
+import { IconThunderbolt } from '@arco-design/web-vue/es/icon';
|
|
|
+
|
|
|
+const route = useRoute();
|
|
|
+const router = useRouter();
|
|
|
+const id = route.params.id as string;
|
|
|
+
|
|
|
+const device = ref<Device>();
|
|
|
+const entities = ref<(HAEntity & { cleaned_value?: number; cleaned_ts?: string; display_name?: string })[]>([]);
|
|
|
+const loading = ref(false);
|
|
|
+const actionLoading = ref(false);
|
|
|
+let timer: any = null;
|
|
|
+
|
|
|
+const formatTime = (iso: string) => {
|
|
|
+ if(!iso) return '-';
|
|
|
+ return new Date(iso).toLocaleString();
|
|
|
+}
|
|
|
+
|
|
|
+const fetchData = async () => {
|
|
|
+ if (!device.value) {
|
|
|
+ // Init Load
|
|
|
+ try {
|
|
|
+ const res = await getDevice(id);
|
|
|
+ device.value = (res as any).data || res;
|
|
|
+ } catch (err) {
|
|
|
+ Message.error('获取设备信息失败');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (device.value && device.value.SourceID && device.value.ExternalID) {
|
|
|
+ try {
|
|
|
+ // 1. Fetch HA Entities (Base)
|
|
|
+ const res = await getSourceDeviceEntities(device.value.SourceID, device.value.ExternalID);
|
|
|
+ const baseEntities = (res as any).data || res;
|
|
|
+
|
|
|
+ // 2. Fetch TSDB Realtime Data (Cleaned)
|
|
|
+ let tsdbData: any[] = [];
|
|
|
+ try {
|
|
|
+ const resTS = await getDeviceRealtime(id);
|
|
|
+ tsdbData = (resTS as any).data || resTS || [];
|
|
|
+ } catch (e) {
|
|
|
+ console.warn("Failed to fetch TSDB data", e);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. Merge and Filter based on Attribute Mapping if exists
|
|
|
+ let finalEntities: (HAEntity & { cleaned_value?: number; cleaned_ts?: string; display_name?: string })[] = [];
|
|
|
+
|
|
|
+ // Map standard keys to user friendly names
|
|
|
+ const attributeLabels: Record<string, string> = {
|
|
|
+ 'power': '有功功率 (W)',
|
|
|
+ 'voltage': '电压 (V)',
|
|
|
+ 'current': '电流 (A)',
|
|
|
+ 'energy': '累计用电 (kWh)',
|
|
|
+ 'temperature': '温度 (°C)'
|
|
|
+ };
|
|
|
+
|
|
|
+ if (device.value.AttributeMapping && Object.keys(device.value.AttributeMapping).length > 0) {
|
|
|
+ // If mapping exists, iterate through MAPPING keys (standard attributes)
|
|
|
+ Object.entries(device.value.AttributeMapping).forEach(([attrKey, entityID]) => {
|
|
|
+ if (attrKey.endsWith('_formula')) return; // skip formula entries
|
|
|
+
|
|
|
+ const ent = baseEntities.find((e: HAEntity) => e.entity_id === entityID);
|
|
|
+
|
|
|
+ // Metric naming in TSDB: sanitized attrKey (not entityID)
|
|
|
+ // Logic in collector: safeMetric = strings.ReplaceAll(metric, ".", "_")
|
|
|
+ // So we look for attrKey in tsdbData
|
|
|
+ const metricName = attrKey.replace(/\./g, '_').replace(/-/g, '_');
|
|
|
+ const matched = tsdbData.find((t: any) => t.metric === metricName);
|
|
|
+
|
|
|
+ if (ent) {
|
|
|
+ finalEntities.push({
|
|
|
+ ...ent,
|
|
|
+ display_name: attributeLabels[attrKey] || attrKey,
|
|
|
+ cleaned_value: matched?.value,
|
|
|
+ cleaned_ts: matched?.ts
|
|
|
+ });
|
|
|
+ } else if (matched) {
|
|
|
+ // Even if HA entity is missing (maybe offline), show TSDB data if available
|
|
|
+ finalEntities.push({
|
|
|
+ entity_id: entityID,
|
|
|
+ state: 'N/A', // HA state unknown
|
|
|
+ attributes: {},
|
|
|
+ last_changed: '',
|
|
|
+ last_updated: '',
|
|
|
+ display_name: attributeLabels[attrKey] || attrKey,
|
|
|
+ cleaned_value: matched.value,
|
|
|
+ cleaned_ts: matched.ts
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Also add controllable entities that might not be mapped but are useful
|
|
|
+ baseEntities.forEach((ent: HAEntity) => {
|
|
|
+ if (canToggle(ent)) {
|
|
|
+ // Check if already added
|
|
|
+ if (!finalEntities.find(e => e.entity_id === ent.entity_id)) {
|
|
|
+ finalEntities.push(ent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ } else {
|
|
|
+ // Fallback: No mapping, use old logic (Entity ID based matching)
|
|
|
+ finalEntities = baseEntities.map((ent: HAEntity) => {
|
|
|
+ const metricName = ent.entity_id.replace(/\./g, '_');
|
|
|
+ const matched = tsdbData.find((t: any) => t.metric === metricName);
|
|
|
+ if (matched) {
|
|
|
+ return { ...ent, cleaned_value: matched.value, cleaned_ts: matched.ts };
|
|
|
+ }
|
|
|
+ return ent;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ entities.value = finalEntities;
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+ console.error(err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleToggle = async (entity: HAEntity) => {
|
|
|
+ if (!device.value?.SourceID) return;
|
|
|
+
|
|
|
+ actionLoading.value = true;
|
|
|
+ const domain = entity.entity_id.split('.')[0];
|
|
|
+ const service = entity.state === 'on' ? 'turn_off' : 'turn_on';
|
|
|
+
|
|
|
+ try {
|
|
|
+ await callSourceService(device.value.SourceID, {
|
|
|
+ domain: domain,
|
|
|
+ service: service,
|
|
|
+ service_data: {
|
|
|
+ entity_id: entity.entity_id
|
|
|
+ }
|
|
|
+ });
|
|
|
+ Message.success('操作指令已发送');
|
|
|
+ // Wait a bit for state to update then refresh
|
|
|
+ setTimeout(fetchData, 1000);
|
|
|
+ } catch (err) {
|
|
|
+ Message.error('操作失败');
|
|
|
+ } finally {
|
|
|
+ actionLoading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const canToggle = (entity: HAEntity) => {
|
|
|
+ const domain = entity.entity_id.split('.')[0];
|
|
|
+ return ['switch', 'light', 'fan', 'input_boolean'].includes(domain);
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ loading.value = true;
|
|
|
+ await fetchData();
|
|
|
+ loading.value = false;
|
|
|
+
|
|
|
+ timer = setInterval(fetchData, 5000);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if(timer) clearInterval(timer);
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+.header {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ background: var(--color-bg-2);
|
|
|
+ padding: 10px;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|