|
|
@@ -1,7 +1,7 @@
|
|
|
<template>
|
|
|
<div class="page-container">
|
|
|
<div class="header">
|
|
|
- <a-typography-title :heading="4" style="margin: 0">智能导入与清洗</a-typography-title>
|
|
|
+ <a-typography-title :heading="4" style="margin: 0">设备管理</a-typography-title>
|
|
|
<div class="filters">
|
|
|
<a-select v-model="filterSource" placeholder="筛选接入源" allow-clear @change="loadData" style="width: 200px; margin-right: 10px">
|
|
|
<a-option v-for="s in sources" :key="s.ID" :label="s.Name" :value="s.ID" />
|
|
|
@@ -28,21 +28,19 @@
|
|
|
<a-table-column title="设备名称" data-index="Name" :width="200" />
|
|
|
<a-table-column title="原始标识" data-index="ExternalID" :width="200" ellipsis tooltip />
|
|
|
<a-table-column title="类型" data-index="DeviceType" :width="120" />
|
|
|
- <a-table-column title="计量模式" data-index="MeteringMode" :width="150">
|
|
|
- <template #cell="{ record }">
|
|
|
- <a-tag v-if="record.MeteringMode === 'VIRTUAL'" color="orange">虚拟 ({{ record.RatedPower }}W)</a-tag>
|
|
|
- <a-tag v-else-if="record.MeteringMode === 'REAL'" color="green">实测</a-tag>
|
|
|
- <a-tag v-else color="gray">未知</a-tag>
|
|
|
- </template>
|
|
|
- </a-table-column>
|
|
|
<a-table-column title="空间位置" data-index="LocationID" :width="150">
|
|
|
<template #cell="{ record }">
|
|
|
{{ getLocationName(record.LocationID) }}
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
- <a-table-column title="操作" :width="100" fixed="right">
|
|
|
+ <a-table-column title="操作" :width="180" fixed="right">
|
|
|
<template #cell="{ record }">
|
|
|
- <a-button type="text" size="small" @click="editDevice(record)">编辑</a-button>
|
|
|
+ <a-space>
|
|
|
+ <a-button type="text" size="small" @click="openDataChart(record)">
|
|
|
+ <template #icon><icon-bar-chart /></template>
|
|
|
+ </a-button>
|
|
|
+ <a-button type="text" size="small" @click="editDevice(record)">编辑</a-button>
|
|
|
+ </a-space>
|
|
|
</template>
|
|
|
</a-table-column>
|
|
|
</template>
|
|
|
@@ -53,9 +51,6 @@
|
|
|
<div class="bottom-toolbar" v-if="selectedDeviceKeys.length > 0">
|
|
|
<span class="selected-count">已选 {{ selectedDeviceKeys.length }} 个设备</span>
|
|
|
<a-divider direction="vertical" />
|
|
|
- <a-button status="warning" @click="openVirtualMeterDialog">
|
|
|
- <template #icon><icon-thunderbolt /></template> 批量设为虚拟计量
|
|
|
- </a-button>
|
|
|
<a-button type="primary" status="success" @click="openLocationDialog" style="margin-left: 10px;">
|
|
|
<template #icon><icon-location /></template> 批量关联空间
|
|
|
</a-button>
|
|
|
@@ -63,14 +58,7 @@
|
|
|
<a-button type="primary" @click="batchImport">确认导入 / 激活</a-button>
|
|
|
</div>
|
|
|
|
|
|
- <!-- Virtual Meter Dialog -->
|
|
|
- <a-modal v-model:visible="virtualDialogVisible" title="批量设为虚拟计量" @ok="applyVirtualMeter" @cancel="virtualDialogVisible = false">
|
|
|
- <a-form :model="{}" layout="vertical">
|
|
|
- <a-form-item label="额定功率(W)">
|
|
|
- <a-input-number v-model="batchRatedPower" :min="0" :step="10" />
|
|
|
- </a-form-item>
|
|
|
- </a-form>
|
|
|
- </a-modal>
|
|
|
+ <!-- Virtual Meter Dialog Removed -->
|
|
|
|
|
|
<!-- Location Dialog -->
|
|
|
<a-modal v-model:visible="locationDialogVisible" title="批量关联空间" @ok="applyLocation" @cancel="locationDialogVisible = false">
|
|
|
@@ -84,15 +72,89 @@
|
|
|
/>
|
|
|
</a-modal>
|
|
|
|
|
|
+ <!-- Edit Dialog -->
|
|
|
+ <a-modal v-model:visible="editVisible" title="设备清洗与配置" @ok="handleEditOk" @cancel="editVisible = false" width="800px">
|
|
|
+ <a-form :model="editForm" layout="vertical">
|
|
|
+ <a-form-item label="设备名称" field="Name">
|
|
|
+ <a-input v-model="editForm.Name" />
|
|
|
+ </a-form-item>
|
|
|
+ <a-form-item label="原始标识" field="ExternalID">
|
|
|
+ <a-input v-model="editForm.ExternalID" disabled />
|
|
|
+ </a-form-item>
|
|
|
+
|
|
|
+ <a-alert type="info" style="margin-bottom: 10px">配置设备属性映射与清洗规则。</a-alert>
|
|
|
+
|
|
|
+ <div v-for="(row, index) in attributeRows" :key="index" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center">
|
|
|
+ <div style="width: 100px; flex-shrink: 0; text-align: right; padding-right: 10px;">
|
|
|
+ <span style="font-weight: bold; white-space: nowrap;">{{ getAttributeLabel(row.key) }}</span>
|
|
|
+ <div style="font-size: 12px; color: var(--color-text-3)" v-if="getAttributeUnit(row.key)">
|
|
|
+ ({{ getAttributeUnit(row.key) }})
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="width: 350px; flex-shrink: 0">
|
|
|
+ <a-select
|
|
|
+ v-model="row.source"
|
|
|
+ placeholder="选择数据源 (HA实体)"
|
|
|
+ allow-search
|
|
|
+ allow-clear
|
|
|
+ style="width: 100%"
|
|
|
+ >
|
|
|
+ <a-option v-for="ent in candidateEntities" :key="ent.entity_id" :value="ent.entity_id">
|
|
|
+ {{ ent.attributes?.friendly_name || ent.entity_id }} ({{ ent.entity_id }})
|
|
|
+ </a-option>
|
|
|
+ </a-select>
|
|
|
+ </div>
|
|
|
+ <div style="flex: 1">
|
|
|
+ <a-input v-model="row.formula" placeholder="清洗公式 (如 x/1000)" style="width: 100%">
|
|
|
+ <template #prepend>f(x)=</template>
|
|
|
+ </a-input>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="attributeRows.length === 0" style="color: var(--color-text-3); text-align: center; padding: 20px; background: var(--color-fill-2)">
|
|
|
+ 暂无标准属性
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <a-form-item label="空间位置" field="LocationID" style="margin-top: 20px">
|
|
|
+ <a-tree-select
|
|
|
+ v-model="editForm.LocationID"
|
|
|
+ :data="locationTree"
|
|
|
+ :field-names="{ key: 'ID', title: 'Name', children: 'children' }"
|
|
|
+ placeholder="请选择空间"
|
|
|
+ allow-clear
|
|
|
+ />
|
|
|
+ </a-form-item>
|
|
|
+ </a-form>
|
|
|
+ </a-modal>
|
|
|
+
|
|
|
+ <!-- Data Chart Modal -->
|
|
|
+ <a-modal v-model:visible="chartVisible" title="历史数据趋势" width="90%" :footer="false" @open="initChart">
|
|
|
+ <div style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center">
|
|
|
+ <a-range-picker v-model="dateRange" show-time format="YYYY-MM-DD HH:mm:ss" style="width: 380px" />
|
|
|
+ <a-select v-model="chartInterval" placeholder="粒度" style="width: 100px">
|
|
|
+ <a-option value="1m">1分钟</a-option>
|
|
|
+ <a-option value="1h">1小时</a-option>
|
|
|
+ <a-option value="1d">1天</a-option>
|
|
|
+ <a-option value="1y">1年</a-option>
|
|
|
+ </a-select>
|
|
|
+ <a-select v-model="chartMetric" placeholder="指标" style="width: 150px">
|
|
|
+ <a-option v-for="attr in standardAttributes" :key="attr.value" :value="attr.value" :label="attr.label" />
|
|
|
+ </a-select>
|
|
|
+ <a-button type="primary" @click="fetchChartData" :loading="chartLoading">查询</a-button>
|
|
|
+ </div>
|
|
|
+ <div ref="chartRef" style="width: 100%; height: 600px;"></div>
|
|
|
+ </a-modal>
|
|
|
+
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
import { ref, onMounted, computed, reactive } from 'vue';
|
|
|
import { Message } from '@arco-design/web-vue';
|
|
|
-import { IconRefresh, IconThunderbolt, IconLocation } from '@arco-design/web-vue/es/icon';
|
|
|
-import { getDevices, getSources, getLocations, updateDevice } from '@/api/resource';
|
|
|
-import type { Device, IntegrationSource, Location as LocType } from '@/api/resource';
|
|
|
+import { getDevices, getSources, getLocations, updateDevice, getSourceDeviceEntities, getDeviceHistory } from '@/api/resource';
|
|
|
+import type { Device, IntegrationSource, Location as LocType, HAEntity } from '@/api/resource';
|
|
|
+import { IconRefresh, IconLocation, IconBarChart } from '@arco-design/web-vue/es/icon';
|
|
|
+import * as echarts from 'echarts';
|
|
|
|
|
|
// --- State ---
|
|
|
const loading = ref(false);
|
|
|
@@ -103,12 +165,37 @@ const filterSource = ref('');
|
|
|
const selectedDeviceKeys = ref<string[]>([]);
|
|
|
const rowSelection = reactive({ type: 'checkbox', showCheckedAll: true, onlyCurrent: false });
|
|
|
|
|
|
-const virtualDialogVisible = ref(false);
|
|
|
-const batchRatedPower = ref(15);
|
|
|
-
|
|
|
const locationDialogVisible = ref(false);
|
|
|
const batchLocationID = ref('');
|
|
|
|
|
|
+const editVisible = ref(false);
|
|
|
+const editForm = reactive<Partial<Device>>({
|
|
|
+ Name: '',
|
|
|
+ AttributeMapping: {},
|
|
|
+ LocationID: ''
|
|
|
+});
|
|
|
+const candidateEntities = ref<HAEntity[]>([]);
|
|
|
+const attributeRows = ref<{ key: string; source: string; formula: string }[]>([]);
|
|
|
+
|
|
|
+// Chart State
|
|
|
+const chartVisible = ref(false);
|
|
|
+const chartRef = ref<HTMLElement | null>(null);
|
|
|
+const chartInstance = ref<echarts.ECharts | null>(null);
|
|
|
+const chartDevice = ref<Device | null>(null);
|
|
|
+const chartMetric = ref('power');
|
|
|
+const chartInterval = ref('1h');
|
|
|
+const dateRange = ref<any[]>([]); // Array of Date or strings
|
|
|
+const chartLoading = ref(false);
|
|
|
+
|
|
|
+const standardAttributes = [
|
|
|
+ { label: '开关状态', value: 'state', unit: '' },
|
|
|
+ { label: '有功功率', value: 'power', unit: 'W' },
|
|
|
+ { label: '电压', value: 'voltage', unit: 'V' },
|
|
|
+ { label: '电流', value: 'current', unit: 'A' },
|
|
|
+ { label: '累计用电', value: 'energy', unit: 'kWh' },
|
|
|
+ { label: '温度', value: 'temperature', unit: '°C' },
|
|
|
+];
|
|
|
+
|
|
|
// --- Lifecycle ---
|
|
|
onMounted(async () => {
|
|
|
await Promise.all([loadSources(), loadLocations()]);
|
|
|
@@ -171,22 +258,150 @@ const getLocationName = (id?: string) => {
|
|
|
return loc ? loc.Name : id;
|
|
|
};
|
|
|
|
|
|
-// --- Interactions ---
|
|
|
+const getAttributeLabel = (key: string) => {
|
|
|
+ const attr = standardAttributes.find(a => a.value === key);
|
|
|
+ return attr ? attr.label : key;
|
|
|
+};
|
|
|
|
|
|
-const openVirtualMeterDialog = () => {
|
|
|
- virtualDialogVisible.value = true;
|
|
|
+const getAttributeUnit = (key: string) => {
|
|
|
+ const attr = standardAttributes.find(a => a.value === key);
|
|
|
+ return attr ? attr.unit : '';
|
|
|
+};
|
|
|
+
|
|
|
+// --- Chart Methods ---
|
|
|
+
|
|
|
+const openDataChart = (record: Device) => {
|
|
|
+ chartDevice.value = record;
|
|
|
+ chartVisible.value = true;
|
|
|
+
|
|
|
+ // Default range: last 24h
|
|
|
+ const end = new Date();
|
|
|
+ const start = new Date();
|
|
|
+ start.setTime(start.getTime() - 3600 * 1000 * 24);
|
|
|
+ dateRange.value = [start, end];
|
|
|
};
|
|
|
|
|
|
-const applyVirtualMeter = async () => {
|
|
|
- for (const id of selectedDeviceKeys.value) {
|
|
|
- await updateDevice(id, { MeteringMode: 'VIRTUAL', RatedPower: batchRatedPower.value });
|
|
|
+const initChart = () => {
|
|
|
+ // Wait for DOM
|
|
|
+ setTimeout(() => {
|
|
|
+ if (chartRef.value) {
|
|
|
+ if (chartInstance.value) {
|
|
|
+ chartInstance.value.dispose();
|
|
|
+ }
|
|
|
+ chartInstance.value = echarts.init(chartRef.value);
|
|
|
+ // Default load
|
|
|
+ fetchChartData();
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+};
|
|
|
+
|
|
|
+const fetchChartData = async () => {
|
|
|
+ if (!chartDevice.value) return;
|
|
|
+
|
|
|
+ // Validate range
|
|
|
+ let startStr = '';
|
|
|
+ let endStr = '';
|
|
|
+
|
|
|
+ if (dateRange.value && dateRange.value.length === 2) {
|
|
|
+ startStr = new Date(dateRange.value[0]).toISOString();
|
|
|
+ endStr = new Date(dateRange.value[1]).toISOString();
|
|
|
+ } else {
|
|
|
+ // Fallback default
|
|
|
+ const end = new Date();
|
|
|
+ const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
|
|
+ startStr = start.toISOString();
|
|
|
+ endStr = end.toISOString();
|
|
|
+ // Update model to reflect default
|
|
|
+ dateRange.value = [start, end];
|
|
|
+ }
|
|
|
+
|
|
|
+ chartLoading.value = true;
|
|
|
+ try {
|
|
|
+ const res = await getDeviceHistory({
|
|
|
+ device_ids: chartDevice.value.ID,
|
|
|
+ metric: chartMetric.value,
|
|
|
+ start: startStr,
|
|
|
+ end: endStr,
|
|
|
+ interval: chartInterval.value
|
|
|
+ });
|
|
|
+ const data = (res as any).data || res;
|
|
|
+
|
|
|
+ renderChart(data);
|
|
|
+ } catch (e) {
|
|
|
+ Message.error('获取数据失败');
|
|
|
+ } finally {
|
|
|
+ chartLoading.value = false;
|
|
|
}
|
|
|
- Message.success('批量设置成功');
|
|
|
- virtualDialogVisible.value = false;
|
|
|
- loadData();
|
|
|
- selectedDeviceKeys.value = [];
|
|
|
};
|
|
|
|
|
|
+const renderChart = (data: any[]) => {
|
|
|
+ if (!chartInstance.value) return;
|
|
|
+
|
|
|
+ const dates = data.map(item => item.ts);
|
|
|
+ // For Candlestick: [open, close, low, high] (ECharts format: open, close, lowest, highest)
|
|
|
+ // Our data: open, high, low, close.
|
|
|
+ // Mapping: [open, close, low, high]
|
|
|
+ const values = data.map(item => [item.open, item.close, item.low, item.high]);
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ title: {
|
|
|
+ text: `${chartDevice.value?.Name} - ${chartMetric.value}`,
|
|
|
+ left: 'center'
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'cross' }
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '5%',
|
|
|
+ right: '5%',
|
|
|
+ bottom: '15%'
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: dates,
|
|
|
+ scale: true,
|
|
|
+ boundaryGap: false,
|
|
|
+ axisLine: { onZero: false },
|
|
|
+ splitLine: { show: false },
|
|
|
+ min: 'dataMin',
|
|
|
+ max: 'dataMax'
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ scale: true,
|
|
|
+ splitArea: { show: true }
|
|
|
+ },
|
|
|
+ dataZoom: [
|
|
|
+ { type: 'inside', start: 0, end: 100 },
|
|
|
+ { show: true, type: 'slider', bottom: '5%', start: 0, end: 100 }
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: chartMetric.value,
|
|
|
+ type: 'candlestick',
|
|
|
+ data: values,
|
|
|
+ itemStyle: {
|
|
|
+ color: '#ec0000',
|
|
|
+ color0: '#00da3c',
|
|
|
+ borderColor: '#8A0000',
|
|
|
+ borderColor0: '#008F28'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'Average',
|
|
|
+ type: 'line',
|
|
|
+ data: data.map(item => item.avg),
|
|
|
+ smooth: true,
|
|
|
+ lineStyle: { opacity: 0.5 }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ chartInstance.value.setOption(option);
|
|
|
+};
|
|
|
+
|
|
|
+// --- Interactions ---
|
|
|
+
|
|
|
const openLocationDialog = () => {
|
|
|
locationDialogVisible.value = true;
|
|
|
};
|
|
|
@@ -210,10 +425,68 @@ const batchImport = async () => {
|
|
|
selectedDeviceKeys.value = [];
|
|
|
};
|
|
|
|
|
|
-const editDevice = (row: Device) => {
|
|
|
- Message.info(`详细编辑功能待实现: ${row.Name}`);
|
|
|
+const editDevice = async (row: Device) => {
|
|
|
+ Object.assign(editForm, JSON.parse(JSON.stringify(row)));
|
|
|
+ // Ensure AttributeMapping exists
|
|
|
+ if (!editForm.AttributeMapping) {
|
|
|
+ editForm.AttributeMapping = {};
|
|
|
+ }
|
|
|
+
|
|
|
+ // Parse AttributeMapping into rows
|
|
|
+ attributeRows.value = [];
|
|
|
+ const map = editForm.AttributeMapping;
|
|
|
+
|
|
|
+ // Load ALL standard attributes
|
|
|
+ standardAttributes.forEach(attr => {
|
|
|
+ attributeRows.value.push({
|
|
|
+ key: attr.value,
|
|
|
+ source: map[attr.value] || '',
|
|
|
+ formula: map[`${attr.value}_formula`] || ''
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ editVisible.value = true;
|
|
|
+
|
|
|
+ // Fetch candidates if we have SourceID and ExternalID (Device ID in HA)
|
|
|
+ if (row.SourceID && row.ExternalID) {
|
|
|
+ try {
|
|
|
+ const res = await getSourceDeviceEntities(row.SourceID, row.ExternalID);
|
|
|
+ candidateEntities.value = (res as any).data || res;
|
|
|
+ } catch (e) {
|
|
|
+ candidateEntities.value = [];
|
|
|
+ Message.warning('无法加载关联的HA实体列表');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ candidateEntities.value = [];
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleEditOk = async () => {
|
|
|
+ if (!editForm.ID) return;
|
|
|
+
|
|
|
+ // Reconstruct AttributeMapping
|
|
|
+ const newMap: Record<string, string> = {};
|
|
|
+ attributeRows.value.forEach(row => {
|
|
|
+ if (row.key && row.source) {
|
|
|
+ newMap[row.key] = row.source;
|
|
|
+ if (row.formula) {
|
|
|
+ newMap[`${row.key}_formula`] = row.formula;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ editForm.AttributeMapping = newMap;
|
|
|
+
|
|
|
+ try {
|
|
|
+ await updateDevice(editForm.ID, editForm);
|
|
|
+ Message.success('设备配置已更新');
|
|
|
+ editVisible.value = false;
|
|
|
+ loadData();
|
|
|
+ } catch (err) {
|
|
|
+ Message.error('更新失败');
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
+
|
|
|
</script>
|
|
|
|
|
|
<style scoped>
|