feat(alerts): 添加微信告警、WebSocket实时推送和地图功能

- 新增微信告警服务(wechat_service)和告警任务(alert_tasks)
- 新增 WebSocket 实时推送端点
- 新增监控管理模块(monitor)
- 增强统计仪表板:趋势图、区域分布、光功率历史
- 设备管理:添加坐标信息、标签系统、复合索引优化
- 前端:重构Dashboard/Charts页面,新增业务组件
- 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名)
- 更新部署配置和脚本
- 新增测试框架基础结构
This commit is contained in:
2026-06-11 15:16:29 +08:00
parent 3453441754
commit b2c20ec43d
45 changed files with 2089 additions and 107 deletions
+47 -2
View File
@@ -16,6 +16,13 @@
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
数据导入
</el-button>
<a href="/api/devices/export/csv" class="csv-export-btn" title="导出CSV">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
导出CSV
</a>
</div>
</div>
@@ -49,6 +56,12 @@
<el-option label="未知" value="unknown" />
</el-select>
</div>
<div class="filter-group">
<label class="filter-label">标签</label>
<el-select v-model="filters.tag" placeholder="全部" clearable @change="search" size="small" style="width:130px">
<el-option v-for="t in availableTags" :key="t" :label="t" :value="t" />
</el-select>
</div>
<div class="filter-group">
<label class="filter-label">搜索</label>
<el-input
@@ -428,6 +441,7 @@
<el-input v-model="editForm.place_type" placeholder="如:宿舍、教室、办公室…" />
</el-form-item>
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="标签"><el-input v-model="editForm.tags" placeholder="多个标签用逗号分隔,如:重点设备,考试用" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
@@ -632,7 +646,8 @@ const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const regions = ref([])
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
const filters = ref({ region: '', keyword: '', school_name: '', status: '', tag: '' })
const availableTags = ref([])
const detailVisible = ref(false)
const selectedDevice = ref({})
@@ -647,7 +662,7 @@ let cooldownTimer = null
const clearing = ref(false)
const editVisible = ref(false)
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '' })
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '', tags: '' })
const regionOptions = ref([])
const editSaving = ref(false)
@@ -1003,6 +1018,7 @@ const openEdit = () => {
room_number: selectedDevice.value.room_number || '',
place_type: selectedDevice.value.place_type || '',
notes: selectedDevice.value.notes || '',
tags: selectedDevice.value.tags || '',
}
loadRegionOptions()
detailVisible.value = false
@@ -1063,6 +1079,7 @@ const loadDevices = async () => {
keyword: filters.value.keyword || undefined,
school_name: filters.value.school_name || undefined,
status: filters.value.status || undefined,
tag: filters.value.tag || undefined,
})
devices.value = data.items
total.value = data.total
@@ -1079,12 +1096,20 @@ const handleSizeChange = (val) => {
loadDevices()
}
const fetchTags = async () => {
try {
const { data } = await request.get('/devices/tags')
availableTags.value = data || []
} catch {}
}
onMounted(() => {
if (route.query.school_name) {
filters.value.keyword = route.query.school_name
}
loadRegions()
loadDevices()
fetchTags()
})
</script>
@@ -1123,6 +1148,26 @@ onMounted(() => {
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.csv-export-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
background: var(--bg-elevated);
border: 1px solid var(--border-default);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
text-decoration: none;
transition: all 0.15s;
}
.csv-export-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
/* 筛选栏 */