|
|
@@ -0,0 +1,266 @@
|
|
|
+# 监控仪表盘 — 实现计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans.
|
|
|
+
|
|
|
+**Goal:** 升级仪表盘为停车监控大屏,4统计卡片+设备状态,10秒轮询
|
|
|
+
|
|
|
+**Architecture:** 后端 `GET /dashboard/monitor`(system 模块,SQL 聚合)。前端替换 `dashboard/index.vue`,`el-statistic` + `setInterval`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件 | 操作 |
|
|
|
+|------|------|
|
|
|
+| `internal/service/system/sys_dashboard.go` | 新建 |
|
|
|
+| `internal/api/v1/system/sys_dashboard.go` | 新建 |
|
|
|
+| `internal/router/system/sys_dashboard.go` | 新建 |
|
|
|
+| `internal/router/enter.go` | 修改 |
|
|
|
+| `internal/initialize/router.go` | 修改 |
|
|
|
+| `frontend/src/view/dashboard/index.vue` | 修改 |
|
|
|
+| `frontend/src/api/dashboard.js` | 修改 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端 API
|
|
|
+
|
|
|
+- [ ] **Step 1: Service**
|
|
|
+
|
|
|
+Write `internal/service/system/sys_dashboard.go`:
|
|
|
+
|
|
|
+```go
|
|
|
+package system
|
|
|
+
|
|
|
+import (
|
|
|
+ "time"
|
|
|
+ "wails-app/internal/global"
|
|
|
+)
|
|
|
+
|
|
|
+type DeviceStatus struct {
|
|
|
+ ChannelName string `json:"channel_name"`
|
|
|
+ Direction string `json:"direction"`
|
|
|
+ DeviceName string `json:"device_name"`
|
|
|
+ DeviceStatus string `json:"device_status"`
|
|
|
+}
|
|
|
+
|
|
|
+type MonitorData struct {
|
|
|
+ TotalSpaces int64 `json:"total_spaces"`
|
|
|
+ UsedSpaces int64 `json:"used_spaces"`
|
|
|
+ InPark int64 `json:"in_park"`
|
|
|
+ TodayIncome float64 `json:"today_income"`
|
|
|
+ TodayEntry int64 `json:"today_entry"`
|
|
|
+ TodayExit int64 `json:"today_exit"`
|
|
|
+ Devices []DeviceStatus `json:"devices"`
|
|
|
+}
|
|
|
+
|
|
|
+type DashboardService struct{}
|
|
|
+
|
|
|
+func (s *DashboardService) GetMonitorData() (MonitorData, error) {
|
|
|
+ var d MonitorData
|
|
|
+ today := time.Now().Format("2006-01-02")
|
|
|
+ global.GVA_DB.Raw("SELECT COALESCE(SUM(capacity), 0) FROM parking_lot").Scan(&d.TotalSpaces)
|
|
|
+ global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE exit_time IS NULL").Scan(&d.InPark)
|
|
|
+ d.UsedSpaces = d.InPark
|
|
|
+ global.GVA_DB.Raw("SELECT COALESCE(SUM(amount), 0) FROM payment_record WHERE paid_at >= ? AND paid_at < ?", today, today+" 23:59:59").Scan(&d.TodayIncome)
|
|
|
+ global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE entry_time >= ? AND entry_time < ?", today, today+" 23:59:59").Scan(&d.TodayEntry)
|
|
|
+ global.GVA_DB.Raw("SELECT COUNT(*) FROM vehicle_record WHERE exit_time >= ? AND exit_time < ?", today, today+" 23:59:59").Scan(&d.TodayExit)
|
|
|
+ global.GVA_DB.Raw(`SELECT ch.channel_name, ch.direction, COALESCE(uhf.device_name, '-') as device_name, COALESCE(uhf.status, 'offline') as device_status FROM channel ch LEFT JOIN uhf_reader uhf ON uhf.channel_id = ch.id WHERE ch.deleted_at IS NULL ORDER BY ch.id`).Scan(&d.Devices)
|
|
|
+ return d, nil
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Handler**
|
|
|
+
|
|
|
+Write `internal/api/v1/system/sys_dashboard.go`:
|
|
|
+
|
|
|
+```go
|
|
|
+package system
|
|
|
+
|
|
|
+import (
|
|
|
+ "github.com/gin-gonic/gin"
|
|
|
+ "wails-app/internal/model/common/response"
|
|
|
+ systemService "wails-app/internal/service/system"
|
|
|
+)
|
|
|
+
|
|
|
+var dashboardSvc = systemService.DashboardService{}
|
|
|
+
|
|
|
+func GetMonitor(c *gin.Context) {
|
|
|
+ data, err := dashboardSvc.GetMonitorData()
|
|
|
+ if err != nil {
|
|
|
+ response.FailWithMessage(err.Error(), c)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ response.OkWithData(data, c)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Router**
|
|
|
+
|
|
|
+Write `internal/router/system/sys_dashboard.go`:
|
|
|
+
|
|
|
+```go
|
|
|
+package system
|
|
|
+
|
|
|
+import (
|
|
|
+ "wails-app/internal/api/v1/system"
|
|
|
+ "github.com/gin-gonic/gin"
|
|
|
+)
|
|
|
+
|
|
|
+type DashboardRouter struct{}
|
|
|
+
|
|
|
+func (s *DashboardRouter) InitDashboardRouter(Router *gin.RouterGroup) {
|
|
|
+ dashboardRouter := Router.Group("dashboard")
|
|
|
+ {
|
|
|
+ dashboardRouter.GET("monitor", system.GetMonitor)
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 注册路由**
|
|
|
+
|
|
|
+Edit `internal/router/enter.go`, add DashboardRouter to RouterGroup:
|
|
|
+
|
|
|
+```go
|
|
|
+type RouterGroup struct {
|
|
|
+ System system.RouterGroup
|
|
|
+ Example example.RouterGroup
|
|
|
+ Vehicle vehicle.RouterGroup
|
|
|
+ Dashboard system.DashboardRouter // add this line
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Edit `internal/initialize/router.go`, register in PrivateGroup block:
|
|
|
+
|
|
|
+```go
|
|
|
+systemRouter.InitDashboardRouter(PrivateGroup) // add this line
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Casbin**
|
|
|
+
|
|
|
+```bash
|
|
|
+sqlite3 "$APPDATA/smart-parking/lc_garage.db" "INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', '888', '/dashboard/monitor', 'GET'), ('p', '618', '/dashboard/monitor', 'GET');"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 编译+提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+go build -o build/bin/smart-parking.exe .
|
|
|
+git add internal/service/system/sys_dashboard.go internal/api/v1/system/sys_dashboard.go internal/router/system/sys_dashboard.go internal/router/enter.go internal/initialize/router.go
|
|
|
+git commit -m "feat: 新增监控仪表盘API——GET /dashboard/monitor"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 前端监控大屏
|
|
|
+
|
|
|
+- [ ] **Step 1: API**
|
|
|
+
|
|
|
+Edit `frontend/src/api/dashboard.js` (add to existing or create):
|
|
|
+
|
|
|
+```js
|
|
|
+import service from '@/utils/request'
|
|
|
+
|
|
|
+export const getMonitor = () => {
|
|
|
+ return service({ url: '/dashboard/monitor', method: 'get' })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 页面**
|
|
|
+
|
|
|
+Write `frontend/src/view/dashboard/index.vue` (replace existing):
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="monitor-page">
|
|
|
+ <div class="monitor-header">
|
|
|
+ <h2>智慧停车监控</h2>
|
|
|
+ <span class="time">{{ now }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-row :gutter="16" class="stat-row">
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card><el-statistic title="车位余量" :value="data.used_spaces + ' / ' + data.total_spaces">
|
|
|
+ <template #suffix><span :style="{ color: data.total_spaces > 0 && data.used_spaces >= data.total_spaces ? '#f56c6c' : '#67c23a' }">车位</span></template>
|
|
|
+ </el-statistic></el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card><el-statistic title="在场车辆" :value="data.in_park">
|
|
|
+ <template #suffix>台</template>
|
|
|
+ </el-statistic></el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card><el-statistic title="今日收入" :value="data.today_income" prefix="¥" :precision="2" /></el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card>
|
|
|
+ <el-statistic title="今日进出" :value="data.today_entry">
|
|
|
+ <template #suffix>/ {{ data.today_exit }}</template>
|
|
|
+ </el-statistic>
|
|
|
+ <span style="font-size:12px;color:#909399">进 / 出</span>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-card class="device-card">
|
|
|
+ <template #header>设备状态</template>
|
|
|
+ <div class="device-list">
|
|
|
+ <div v-for="d in data.devices" :key="d.channel_name" class="device-item">
|
|
|
+ <el-tag :type="d.device_status === 'online' ? 'success' : 'danger'" size="small">{{ d.device_status === 'online' ? '在线' : '离线' }}</el-tag>
|
|
|
+ <span>{{ d.channel_name }}</span>
|
|
|
+ <span class="device-name">{{ d.device_name }} ({{ d.direction === 'in' ? '入口' : '出口' }})</span>
|
|
|
+ </div>
|
|
|
+ <el-empty v-if="!data.devices || data.devices.length === 0" description="暂无设备" :image-size="60" />
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
|
|
+import { getMonitor } from '@/api/dashboard'
|
|
|
+
|
|
|
+const data = reactive({ total_spaces: 0, used_spaces: 0, in_park: 0, today_income: 0, today_entry: 0, today_exit: 0, devices: [] })
|
|
|
+const now = ref('')
|
|
|
+let timer = null
|
|
|
+
|
|
|
+function updateTime() {
|
|
|
+ now.value = new Date().toLocaleString('zh-CN')
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchData() {
|
|
|
+ try {
|
|
|
+ const res = await getMonitor()
|
|
|
+ if (res.code === 0) Object.assign(data, res.data)
|
|
|
+ } catch (e) { /* keep last data on error */ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ updateTime()
|
|
|
+ fetchData()
|
|
|
+ timer = setInterval(() => { updateTime(); fetchData() }, 10000)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => clearInterval(timer))
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.monitor-page { padding: 16px; }
|
|
|
+.monitor-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
|
+.monitor-header h2 { margin: 0; }
|
|
|
+.time { color: #909399; font-size: 14px; }
|
|
|
+.stat-row { margin-bottom: 16px; }
|
|
|
+.stat-row .el-card { text-align: center; }
|
|
|
+.device-card { margin-top: 16px; }
|
|
|
+.device-list { display: flex; flex-wrap: wrap; gap: 12px; }
|
|
|
+.device-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #f5f7fa; border-radius: 6px; }
|
|
|
+.device-name { color: #909399; font-size: 13px; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译+提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd frontend && npm run build && cd ..
|
|
|
+git add frontend/src/view/dashboard/index.vue frontend/src/api/dashboard.js
|
|
|
+git commit -m "feat: 升级仪表盘为停车监控大屏——4统计卡片+设备状态+10秒轮询"
|
|
|
+```
|