liuq пре 3 месеци
родитељ
комит
abfdb8f479

+ 100 - 4
backend/controllers/resource_controller.go

@@ -589,6 +589,79 @@ func GetSourceCandidates(c *gin.Context) {
 	c.JSON(http.StatusOK, entities)
 }
 
+// CallSourceService 调用数据源服务 (Home Assistant Call Service)
+type ServiceCallReq struct {
+	Domain      string                 `json:"domain"`
+	Service     string                 `json:"service"`
+	ServiceData map[string]interface{} `json:"service_data"`
+}
+
+func CallSourceService(c *gin.Context) {
+	id := c.Param("id")
+	var req ServiceCallReq
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	var source models.IntegrationSource
+	if err := models.DB.First(&source, "id = ?", id).Error; err != nil {
+		c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"})
+		return
+	}
+
+	if source.DriverType != "HOME_ASSISTANT" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "Only Home Assistant sources are supported"})
+		return
+	}
+
+	// Call HA API
+	var haConfig HAConfig
+	b, _ := source.Config.MarshalJSON()
+	json.Unmarshal(b, &haConfig) // Error ignored as DB data should be valid
+
+	if haConfig.URL == "" || haConfig.Token == "" {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid source configuration"})
+		return
+	}
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	url := haConfig.URL
+	url = strings.TrimSuffix(url, "/")
+	url = strings.TrimSuffix(url, "/api")
+
+	// Target URL: /api/services/<domain>/<service>
+	targetURL := fmt.Sprintf("%s/api/services/%s/%s", url, req.Domain, req.Service)
+	
+	reqBody, _ := json.Marshal(req.ServiceData)
+	httpReq, err := http.NewRequest("POST", targetURL, bytes.NewBuffer(reqBody))
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request: " + err.Error()})
+		return
+	}
+	httpReq.Header.Set("Authorization", "Bearer "+haConfig.Token)
+	httpReq.Header.Set("Content-Type", "application/json")
+
+	resp, err := client.Do(httpReq)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Connection failed: " + err.Error()})
+		return
+	}
+	defer resp.Body.Close()
+
+	bodyBytes, _ := io.ReadAll(resp.Body)
+	if resp.StatusCode != 200 && resp.StatusCode != 201 {
+		c.JSON(http.StatusBadGateway, gin.H{"error": "HA Error", "details": string(bodyBytes)})
+		return
+	}
+
+	// HA usually returns a list of state changes
+	var result interface{}
+	json.Unmarshal(bodyBytes, &result)
+	
+	c.JSON(http.StatusOK, result)
+}
+
 // --- Device Controllers ---
 
 func GetDevices(c *gin.Context) {
@@ -612,6 +685,29 @@ func GetDevices(c *gin.Context) {
 	c.JSON(http.StatusOK, devices)
 }
 
+func GetDevice(c *gin.Context) {
+	id := c.Param("id")
+	var device models.Device
+	if err := models.DB.First(&device, "id = ?", id).Error; err != nil {
+		c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
+		return
+	}
+	c.JSON(http.StatusOK, device)
+}
+
+func GetDeviceRealtime(c *gin.Context) {
+	id := c.Param("id")
+	
+	// Fetch latest data from TDengine (Cleaned Data)
+	data, err := db.GetLatestDeviceData(id)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	
+	c.JSON(http.StatusOK, data)
+}
+
 func CreateDevice(c *gin.Context) {
 	var device models.Device
 	if err := c.ShouldBindJSON(&device); err != nil {
@@ -731,14 +827,14 @@ func GetDeviceHistory(c *gin.Context) {
 	if startStr != "" {
 		if t, err := time.Parse(time.RFC3339, startStr); err == nil {
 			start = t
-		} else if t, err := time.Parse("2006-01-02 15:04:05", startStr); err == nil {
+		} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", startStr, time.Local); err == nil {
 			start = t
 		}
 	}
 	if endStr != "" {
 		if t, err := time.Parse(time.RFC3339, endStr); err == nil {
 			end = t
-		} else if t, err := time.Parse("2006-01-02 15:04:05", endStr); err == nil {
+		} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", endStr, time.Local); err == nil {
 			end = t
 		}
 	}
@@ -768,7 +864,7 @@ func DeleteDeviceHistory(c *gin.Context) {
 	var err error
 
 	// Parse Start
-	if start, err = time.Parse("2006-01-02 15:04:05", startStr); err != nil {
+	if start, err = time.ParseInLocation("2006-01-02 15:04:05", startStr, time.Local); err != nil {
 		if start, err = time.Parse(time.RFC3339, startStr); err != nil {
 			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start format"})
 			return
@@ -776,7 +872,7 @@ func DeleteDeviceHistory(c *gin.Context) {
 	}
 
 	// Parse End
-	if end, err = time.Parse("2006-01-02 15:04:05", endStr); err != nil {
+	if end, err = time.ParseInLocation("2006-01-02 15:04:05", endStr, time.Local); err != nil {
 		if end, err = time.Parse(time.RFC3339, endStr); err != nil {
 			c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end format"})
 			return

+ 71 - 19
backend/db/tdengine.go

@@ -195,18 +195,19 @@ func GetReadings(deviceIDs []string, metric string, start, end time.Time, interv
 
 	var query string
 	
-	// 如果 interval 为 "raw",查询原始数据(不聚合)
-	// 这有助于在数据量少时直接查看,或者调试
-	if interval == "raw" {
-		// 限制 2000 条以防止数据量过大
-		// 选择 val 重复 5 次是为了复用 Scan 逻辑 (Open, High, Low, Close, Avg)
-		query = fmt.Sprintf(`SELECT 
-			ts, val, val, val, val, val, device_id
+		// 如果 interval 为 "raw",查询原始数据(不聚合)
+		// 这有助于在数据量少时直接查看,或者调试
+		if interval == "raw" {
+			// 限制 2000 条以防止数据量过大
+			// 选择 val 重复 5 次是为了复用 Scan 逻辑 (Open, High, Low, Close, Avg)
+			// 注意:移除了 device_id 列,以匹配 Scan 的 6 个参数
+			query = fmt.Sprintf(`SELECT 
+			ts, val, val, val, val, val
 			FROM readings 
 			WHERE device_id IN (%s) %s AND ts >= '%s' AND ts <= '%s' 
 			ORDER BY ts ASC LIMIT 2000`,
-			inClause, metricFilter, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"))
-	} else {
+			inClause, metricFilter, start.In(time.Local).Format("2006-01-02 15:04:05"), end.In(time.Local).Format("2006-01-02 15:04:05"))
+		} else {
 		// 聚合查询
 		if interval == "" {
 			interval = "1h"
@@ -215,16 +216,18 @@ func GetReadings(deviceIDs []string, metric string, start, end time.Time, interv
 			interval = "365d"
 		}
 
-		// TDengine Query: Simplified to avoid GROUP BY + INTERVAL conflict
-		query = fmt.Sprintf(`SELECT 
-			_wstart, FIRST(val), MAX(val), MIN(val), LAST(val), AVG(val)
-			FROM readings 
-			WHERE device_id IN (%s) %s AND ts >= '%s' AND ts <= '%s' 
-			INTERVAL(%s)
-			ORDER BY _wstart ASC`,
-			inClause, metricFilter, start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), interval)
+	// TDengine Query: Simplified to avoid GROUP BY + INTERVAL conflict
+	query = fmt.Sprintf(`SELECT 
+		_wstart, FIRST(val), MAX(val), MIN(val), LAST(val), AVG(val)
+		FROM readings 
+		WHERE device_id IN (%s) %s AND ts >= '%s' AND ts <= '%s' 
+		INTERVAL(%s)
+		ORDER BY _wstart ASC`,
+		inClause, metricFilter, start.In(time.Local).Format("2006-01-02 15:04:05"), end.In(time.Local).Format("2006-01-02 15:04:05"), interval)
 	}
 
+	log.Printf("DEBUG: Executing Query: %s", query)
+
 	rows, err := TD.Query(query)
 	if err != nil {
 		log.Printf("TDengine Query Error: %v\nQuery: %s", err, query)
@@ -236,15 +239,21 @@ func GetReadings(deviceIDs []string, metric string, start, end time.Time, interv
 	for rows.Next() {
 		var r ReadingHistory
 		var ts time.Time
-		// var did sql.NullString 
+		var open, high, low, closeVal, avg sql.NullFloat64
 
 		// Result columns: ts, open, high, low, close, avg
-		if err := rows.Scan(&ts, &r.Open, &r.High, &r.Low, &r.Close, &r.Avg); err != nil {
+		if err := rows.Scan(&ts, &open, &high, &low, &closeVal, &avg); err != nil {
 			log.Printf("Scan error: %v", err)
 			continue
 		}
 		r.Ts = ts.Format("2006-01-02 15:04:05")
 		
+		if open.Valid { r.Open = open.Float64 }
+		if high.Valid { r.High = high.Float64 }
+		if low.Valid { r.Low = low.Float64 }
+		if closeVal.Valid { r.Close = closeVal.Float64 }
+		if avg.Valid { r.Avg = avg.Float64 }
+		
 		// If we only queried one device, we can fill it here
 		if len(deviceIDs) == 1 {
 			r.DeviceID = deviceIDs[0]
@@ -252,6 +261,7 @@ func GetReadings(deviceIDs []string, metric string, start, end time.Time, interv
 		
 		results = append(results, r)
 	}
+	log.Printf("DEBUG: Found %d rows", len(results))
 	return results, nil
 }
 
@@ -283,3 +293,45 @@ func DeleteReadings(deviceID string, metric string, start, end time.Time) error
 	}
 	return err
 }
+
+// LatestData represents the most recent data point for a metric
+type LatestData struct {
+	Metric string  `json:"metric"`
+	Value  float64 `json:"value"`
+	Ts     string  `json:"ts"`
+}
+
+// GetLatestDeviceData fetches the last known value for all metrics of a device
+func GetLatestDeviceData(deviceID string) ([]LatestData, error) {
+	if TD == nil {
+		return nil, fmt.Errorf("TDengine not initialized")
+	}
+
+	// Query last value for each metric for the given device
+	// Note: device_id is a tag, so we can group by metric (also a tag)
+	query := fmt.Sprintf(`SELECT LAST(ts), LAST(val), metric FROM readings WHERE device_id = '%s' GROUP BY metric`, deviceID)
+
+	rows, err := TD.Query(query)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var results []LatestData
+	for rows.Next() {
+		var ts time.Time
+		var val float64
+		var metric string
+		
+		if err := rows.Scan(&ts, &val, &metric); err != nil {
+			continue
+		}
+		
+		results = append(results, LatestData{
+			Metric: metric,
+			Value:  val,
+			Ts:     ts.Format("2006-01-02 15:04:05"),
+		})
+	}
+	return results, nil
+}

+ 1 - 1
backend/models/init.go

@@ -16,7 +16,7 @@ func InitDB() {
 	dsn := os.Getenv("DB_DSN")
 	if dsn == "" {
 		// Fallback for local development if not in env
-		dsn = "host=localhost user=ems password=ems_pass dbname=ems port=5433 sslmode=disable"
+		dsn = "host=localhost user=ems password=ems_pass dbname=ems port=5433 sslmode=disable TimeZone=Asia/Shanghai"
 	}
 
 	var err error

+ 3 - 0
backend/routes/routes.go

@@ -29,9 +29,12 @@ func SetupRoutes(r *gin.Engine) {
 		api.GET("/sources/:id/devices", controllers.GetSourceDevices) // 获取HA设备列表
 		api.GET("/sources/:id/devices/:deviceId/entities", controllers.GetSourceDeviceEntities) // 获取HA设备实体
 		api.POST("/sources/:id/sync", controllers.SyncSource)       // 立即同步
+		api.POST("/sources/:id/service", controllers.CallSourceService) // 调用服务
 
 		// Devices
 		api.GET("/devices", controllers.GetDevices)
+		api.GET("/devices/:id", controllers.GetDevice)
+		api.GET("/devices/:id/realtime", controllers.GetDeviceRealtime) // 获取时序库最新数据
 		api.GET("/devices/history", controllers.GetDeviceHistory)
 		api.DELETE("/devices/history", controllers.DeleteDeviceHistory)
 		api.POST("/devices", controllers.CreateDevice)

+ 5 - 20
backend/services/collector.go

@@ -28,12 +28,12 @@ func (s *CollectorService) Start() {
 	// Spec: "0 * * * * *" -> Every minute at 00s
 	// For testing, maybe every 10 seconds? "*/10 * * * * *"
 	// Let's stick to every minute for production-like behavior, or 30s.
-	_, err := s.cron.AddFunc("*/30 * * * * *", s.collectJob)
+	_, err := s.cron.AddFunc("*/5 * * * * *", s.collectJob)
 	if err != nil {
 		log.Fatalf("Failed to start collector cron: %v", err)
 	}
 	s.cron.Start()
-	log.Println("Data Collector Service started (interval: 30s)")
+	log.Println("Data Collector Service started (interval: 5s)")
 }
 
 func (s *CollectorService) Stop() {
@@ -225,27 +225,12 @@ func (s *CollectorService) processSourceGroup(sourceID string, devices []models.
 	count := 0
 	
 	for entityID, valStr := range states {
-		// Handle Switch Status (on/off) explicitly
-		if valStr == "on" || valStr == "off" {
-			valBool := valStr == "on"
-			targets := requestMap[entityID]
-			for _, target := range targets {
-				// Insert Switch Log
-				err := db.InsertSwitchLog(target.DeviceID, target.Metric, valBool, target.LocationID, now)
-				if err != nil {
-					log.Printf("TSDB Insert Switch Log Error for device %s (metric: %s, entity: %s): %v", target.DeviceID, target.Metric, entityID, err)
-				} else {
-					count++
-				}
-			}
-			continue
-		}
-
 		// Parse value to float
 		val, err := strconv.ParseFloat(valStr, 64)
 		if err != nil {
-			log.Printf("Skipping non-numeric value for entity %s: %s", entityID, valStr)
-			continue // Skip non-numeric
+			// Skip non-numeric values (including 'on'/'off', 'unknown', text states etc.)
+			// User requested not to store switch/text data, so we silently skip.
+			continue
 		}
 
 		targets := requestMap[entityID]

+ 7 - 1
docker-compose.wsl.yml

@@ -25,7 +25,7 @@ services:
       timeout: 5s
       retries: 5
     environment:
-      - DB_DSN=host=postgres user=${POSTGRES_USER} password=${DB_PASSWORD} dbname=${POSTGRES_DB} port=${POSTGRES_PORT} sslmode=disable
+      - DB_DSN=host=postgres user=${POSTGRES_USER} password=${DB_PASSWORD} dbname=${POSTGRES_DB} port=${POSTGRES_PORT} sslmode=disable TimeZone=Asia/Shanghai
       - TD_DSN=root:taosdata@http(${TD_HOST}:6041)/power_db
       - REDIS_ADDR=${REDIS_HOST}:${REDIS_PORT}
       - POSTGRES_USER=${POSTGRES_USER}
@@ -35,6 +35,7 @@ services:
       - POSTGRES_PORT=${POSTGRES_PORT}
       - JWT_SECRET=${JWT_SECRET}
       - JWT_EXPIRE_HOURS=${JWT_EXPIRE_HOURS}
+      - TZ=Asia/Shanghai
     volumes:
       - ./backend/logs:/app/logs
     ports:
@@ -53,6 +54,7 @@ services:
       POSTGRES_USER: ${POSTGRES_USER}
       POSTGRES_PASSWORD: ${DB_PASSWORD}
       POSTGRES_DB: ${POSTGRES_DB}
+      TZ: Asia/Shanghai
     volumes:
       - pg_data:/var/lib/postgresql/data
     ports:
@@ -71,6 +73,7 @@ services:
     environment:
       - TAOS_FIRST_EP=tdengine:6030
       - TAOS_FQDN=tdengine
+      - TZ=Asia/Shanghai
 
   # 5. Cache: Redis
   redis:
@@ -79,6 +82,8 @@ services:
     ports: ["6379:6379"]
     volumes:
       - ./data/redis:/data
+    environment:
+      - TZ=Asia/Shanghai
 
   # 6. Message Broker: EMQX (MQTT)
   emqx:
@@ -91,6 +96,7 @@ services:
     #   - ./configs/emqx:/opt/emqx/etc
     environment:
       - EMQX_ALLOW_ANONYMOUS=true # For dev only
+      - TZ=Asia/Shanghai
 
   # 7. Backup Service (Sidecar)
   pg-backup:

+ 12 - 0
frontend/src/api/resource.ts

@@ -80,6 +80,10 @@ export const getSourceDeviceEntities = (id: string, deviceId: string) => {
   return api.get<any, HAEntity[]>(`/sources/${id}/devices/${deviceId}/entities`);
 };
 
+export const callSourceService = (id: string, data: { domain: string; service: string; service_data: any }) => {
+  return api.post<any, any>(`/sources/${id}/service`, data);
+};
+
 // --- Device APIs ---
 
 export interface DeviceHistoryParams {
@@ -104,6 +108,14 @@ export const getDevices = (params?: any) => {
   return api.get<any, Device[]>('/devices', { params });
 };
 
+export const getDevice = (id: string) => {
+  return api.get<any, Device>(`/devices/${id}`);
+};
+
+export const getDeviceRealtime = (id: string) => {
+  return api.get<any, { metric: string, value: number, ts: string }[]>(`/devices/${id}/realtime`);
+};
+
 export const getDeviceHistory = (params: DeviceHistoryParams) => {
   return api.get<any, HistoryData[]>('/devices/history', { params });
 };

+ 10 - 0
frontend/src/stores/permission.ts

@@ -112,6 +112,16 @@ export const usePermissionStore = defineStore('permission', {
         const rewriteRoutes = filterAsyncRoutes(rdata, false, true);
         console.log('Rewrite routes:', rewriteRoutes);
 
+        // Add Device Live View Route manually (Hidden)
+        // Using lazy loading for the component
+        rewriteRoutes.push({
+            path: '/monitor/device-live/:id',
+            name: 'DeviceLive',
+            component: () => import('../views/monitor/DeviceLive.vue'),
+            meta: { title: '设备实时数据', hidden: true, activeMenu: '/monitor/device-control' },
+            children: [] 
+        } as any);
+
         // Add 404 catch-all route at the end
         rewriteRoutes.push({
             path: '/:pathMatch(.*)*',

+ 8 - 1
frontend/src/views/monitor/DeviceControl.vue

@@ -24,9 +24,10 @@
             </template>
           </a-table-column>
           <a-table-column title="额定功率(W)" data-index="RatedPower" />
-          <a-table-column title="操作" :width="150">
+          <a-table-column title="操作" :width="200">
             <template #cell="{ record }">
               <a-space>
+                <a-button type="text" size="small" @click="handleMonitor(record)">实时数据</a-button>
                 <a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
                 <a-button type="text" status="danger" size="small" @click="handleDelete(record)">删除</a-button>
               </a-space>
@@ -64,6 +65,7 @@
 
 <script setup lang="ts">
 import { ref, onMounted, reactive } from 'vue';
+import { useRouter } from 'vue-router';
 import { getDevices, createDevice, updateDevice, deleteDevice, type Device } from '../../api/resource';
 import { Message, Modal } from '@arco-design/web-vue';
 import { IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon';
@@ -72,6 +74,7 @@ const devices = ref<Device[]>([]);
 const loading = ref(false);
 const dialogVisible = ref(false);
 const isEdit = ref(false);
+const router = useRouter();
 
 const form = reactive<Partial<Device>>({
   Name: '',
@@ -92,6 +95,10 @@ const fetchDevices = async () => {
   }
 };
 
+const handleMonitor = (row: Device) => {
+  router.push({ name: 'DeviceLive', params: { id: row.ID } });
+};
+
 const handleEdit = (row: Device) => {
   isEdit.value = true;
   Object.assign(form, row);

+ 249 - 0
frontend/src/views/monitor/DeviceLive.vue

@@ -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>
+

+ 0 - 2
frontend/src/views/resource/DataSource.vue

@@ -201,7 +201,6 @@ const filterKeyword = ref('');
 
 const templates: Record<string, Record<string, string>> = {
   'ELEC': {
-    'switch': '开关 (Switch)',
     'power': '电功率 (Power, W)',
     'voltage': '电压 (Voltage, V)',
     'current': '电流 (Current, A)',
@@ -427,7 +426,6 @@ const getFilteredCandidates = (attrKey?: string) => {
   if (!attrKey) return data;
 
   const keywords: Record<string, string[]> = {
-    'switch': ['switch', '开关'],
     'power': ['power', '功率'],
     'voltage': ['voltage', '电压'],
     'current': ['current', '电流'],

+ 54 - 11
frontend/src/views/resource/ImportClean.vue

@@ -33,9 +33,12 @@
                  {{ getLocationName(record.LocationID) }}
              </template>
           </a-table-column>
-          <a-table-column title="操作" :width="180" fixed="right">
+          <a-table-column title="操作" :width="200" fixed="right">
             <template #cell="{ record }">
                <a-space>
+                 <a-button type="text" size="small" @click="handleMonitor(record)">
+                    实时状态
+                 </a-button>
                  <a-button type="text" size="small" @click="openDataChart(record)">
                     <template #icon><icon-bar-chart /></template>
                  </a-button>
@@ -128,7 +131,7 @@
     </a-modal>
 
     <!-- Data Chart Modal -->
-    <a-modal v-model:visible="chartVisible" title="历史数据趋势" width="90%" :footer="false" @open="initChart">
+    <a-modal v-model:visible="chartVisible" title="历史数据趋势" width="90%" :footer="false" @open="initChart" unmount-on-close>
         <div style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center">
             <a-range-picker 
                 v-model="dateRange" 
@@ -183,6 +186,7 @@
 
 <script setup lang="ts">
 import { ref, onMounted, computed, reactive } from 'vue';
+import { useRouter } from 'vue-router';
 import { Message } from '@arco-design/web-vue';
 import { getDevices, getSources, getLocations, updateDevice, getSourceDeviceEntities, getDeviceHistory, deleteDeviceHistory } from '@/api/resource';
 import type { Device, IntegrationSource, Location as LocType, HAEntity } from '@/api/resource';
@@ -190,6 +194,7 @@ import { IconRefresh, IconLocation, IconBarChart, IconLeft, IconRight, IconDelet
 import * as echarts from 'echarts';
 
 // --- State ---
+const router = useRouter();
 const loading = ref(false);
 const devices = ref<Device[]>([]);
 const sources = ref<IntegrationSource[]>([]);
@@ -219,6 +224,7 @@ const chartMetric = ref('energy');
 const chartInterval = ref('1h');
 const dateRange = ref<any[]>([]); // Array of Date or strings
 const chartLoading = ref(false);
+const currentRequestId = ref(0);
 
 // Delete Dialog State
 const deleteDialogVisible = ref(false);
@@ -266,7 +272,6 @@ const rangeShortcuts = [
 ];
 
 const standardAttributes = [
-    { label: '开关状态', value: 'state', unit: '' },
     { label: '有功功率', value: 'power', unit: 'W' },
     { label: '电压', value: 'voltage', unit: 'V' },
     { label: '电流', value: 'current', unit: 'A' },
@@ -348,14 +353,18 @@ const getAttributeUnit = (key: string) => {
 
 // --- Chart Methods ---
 
+const handleMonitor = (record: Device) => {
+  router.push({ name: 'DeviceLive', params: { id: record.ID } });
+};
+
 const openDataChart = (record: Device) => {
     chartDevice.value = record;
     chartVisible.value = true;
     
-    // Default range: last 24h
+    // Default range: last 1h
     const end = new Date();
     const start = new Date();
-    start.setTime(start.getTime() - 3600 * 1000 * 24);
+    start.setTime(start.getTime() - 3600 * 1000);
     dateRange.value = [start, end]; 
 };
 
@@ -412,6 +421,8 @@ const handleDeleteData = async () => {
 
 const fetchChartData = async () => {
     if (!chartDevice.value) return;
+
+    const requestId = ++currentRequestId.value;
     
     // Validate range
     let startStr = '';
@@ -423,14 +434,35 @@ const fetchChartData = async () => {
     } else {
         // Fallback default
         const end = new Date();
-        const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
+        const start = new Date(end.getTime() - 60 * 60 * 1000);
         startStr = start.toISOString();
         endStr = end.toISOString();
         // Update model to reflect default
         dateRange.value = [start, end];
     }
     
+    // Auto-calculate interval
+    const s = new Date(startStr);
+    const e = new Date(endStr);
+    const diffMs = e.getTime() - s.getTime();
+    const diffHours = diffMs / (1000 * 3600);
+    
+    if (diffHours <= 1) {
+        chartInterval.value = '1m';
+    } else if (diffHours <= 12) {
+        chartInterval.value = '15m';
+    } else if (diffHours <= 24) {
+        chartInterval.value = '1h';
+    } else if (diffHours <= 24 * 7) {
+        chartInterval.value = '6h';
+    } else {
+        chartInterval.value = '1d';
+    }
+    
     chartLoading.value = true;
+    if (chartInstance.value) {
+        chartInstance.value.showLoading();
+    }
     try {
         const res = await getDeviceHistory({
             device_ids: chartDevice.value.ID,
@@ -439,18 +471,29 @@ const fetchChartData = async () => {
             end: endStr,
             interval: chartInterval.value
         });
+
+        if (requestId !== currentRequestId.value) return;
+
         const data = (res as any).data || res;
         
         renderChart(data);
     } catch (e) {
-        Message.error('获取数据失败');
+        if (requestId === currentRequestId.value) {
+            Message.error('获取数据失败');
+        }
     } finally {
-        chartLoading.value = false;
+        if (requestId === currentRequestId.value) {
+            chartLoading.value = false;
+            if (chartInstance.value) {
+                chartInstance.value.hideLoading();
+            }
+        }
     }
 };
 
 const renderChart = (data: any[]) => {
     if (!chartInstance.value) return;
+    chartInstance.value.clear();
     
     const dates = data.map(item => item.ts);
     const isState = chartMetric.value === 'state';
@@ -471,7 +514,7 @@ const renderChart = (data: any[]) => {
         option = {
             tooltip: {
                 trigger: 'axis',
-                axisPointer: { type: 'cross' },
+                axisPointer: { type: 'line' },
                 confine: true,
                 formatter: (params: any) => {
                     if (!params || params.length === 0) return '';
@@ -531,7 +574,7 @@ const renderChart = (data: any[]) => {
             tooltip: {
                 trigger: 'axis',
                 axisPointer: { 
-                    type: 'cross',
+                    type: 'line',
                     label: {
                         backgroundColor: '#6a7985'
                     }
@@ -575,7 +618,7 @@ const renderChart = (data: any[]) => {
                     type: 'line',
                     data: values,
                     smooth: true,
-                    showSymbol: false,
+                    showSymbol: true,
                     symbol: 'circle',
                     symbolSize: 6,
                     emphasis: {