feat(alerts): 添加微信告警、WebSocket实时推送和地图功能
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks) - 新增 WebSocket 实时推送端点 - 新增监控管理模块(monitor) - 增强统计仪表板:趋势图、区域分布、光功率历史 - 设备管理:添加坐标信息、标签系统、复合索引优化 - 前端:重构Dashboard/Charts页面,新增业务组件 - 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名) - 更新部署配置和脚本 - 新增测试框架基础结构
This commit is contained in:
@@ -5,3 +5,11 @@ export const getSummary = () => request.get('/stats/summary')
|
||||
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
||||
|
||||
export const getByRegion = () => request.get('/stats/by-region')
|
||||
|
||||
export const getOltStats = () => request.get('/stats/olt-stats')
|
||||
|
||||
export const getModelDistribution = () => request.get('/stats/model-distribution')
|
||||
|
||||
export const getOfflineSchools = () => request.get('/stats/offline-schools')
|
||||
|
||||
|
||||
|
||||
@@ -210,14 +210,23 @@ const updateTime = () => {
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
let timer = null
|
||||
|
||||
// 键盘快捷键
|
||||
const shortcuts = { '1': '/dashboard', '2': '/devices', '3': '/charts', '4': '/olt', '5': '/inventory' }
|
||||
const onKeydown = (e) => {
|
||||
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); document.querySelector('.search-input input')?.focus() }
|
||||
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchProfile()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
||||
|
||||
const allNavItems = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||||
<div class="import-hint">请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。</div>
|
||||
<div class="import-actions">
|
||||
<el-button size="small" @click="$emit('download-template')">下载导入模板</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="(f) => $emit('file-change', f)"
|
||||
:on-remove="() => $emit('file-remove')"
|
||||
drag
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<div class="upload-area">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<div style="font-size:13px;color:var(--text-secondary)">拖拽文件到此处,或 <em style="color:var(--accent)">点击选择</em></div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">支持 .xlsx / .xls 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="result" style="margin-top:16px">
|
||||
<el-alert
|
||||
:type="result.failed?.length ? 'warning' : 'success'"
|
||||
:title="`导入完成:成功 ${result.success} 条${result.failed?.length ? ',失败 ' + result.failed.length + ' 条' : ''}`"
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="result.failed?.length" style="margin-top:10px;max-height:160px;overflow-y:auto">
|
||||
<div v-for="f in result.failed" :key="f.row" style="font-size:12px;color:var(--danger);padding:2px 0">
|
||||
第 {{ f.row }} 行<template v-if="f.mac">({{ f.mac }})</template>:{{ f.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false); $emit('close')">关闭</el-button>
|
||||
<el-button type="primary" :loading="loading" :disabled="!hasFile" @click="$emit('import')">开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
hasFile: { type: Boolean, default: false },
|
||||
result: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['update:visible', 'download-template', 'file-change', 'file-remove', 'import', 'close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-hint { font-size:13px; color:var(--text-muted); margin-bottom:12px; }
|
||||
.import-actions { margin-bottom:4px; }
|
||||
.upload-area { display:flex; flex-direction:column; align-items:center; padding:20px 0; }
|
||||
</style>
|
||||
@@ -42,7 +42,7 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
.about-page {
|
||||
padding: 24px 28px;
|
||||
max-width: 860px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
+156
-61
@@ -31,17 +31,59 @@
|
||||
</div>
|
||||
<div ref="regionChart" class="chart-area"></div>
|
||||
</div>
|
||||
|
||||
<!-- OLT 设备在线率 -->
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||
<rect x="2" y="2" width="20" height="8" rx="1"/><rect x="2" y="14" width="20" height="8" rx="1"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||
</svg>
|
||||
OLT 设备在线率
|
||||
</span>
|
||||
</div>
|
||||
<div ref="oltChart" class="chart-area" style="height:360px"></div>
|
||||
</div>
|
||||
|
||||
<!-- 设备型号分布 -->
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
设备型号分布
|
||||
</span>
|
||||
</div>
|
||||
<div ref="modelChart" class="chart-area" style="height:360px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getTrend, getByRegion } from '../api/stats'
|
||||
import { getTrend, getByRegion, getOltStats, getModelDistribution } from '../api/stats'
|
||||
|
||||
const trendChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
const oltChart = ref(null)
|
||||
const modelChart = ref(null)
|
||||
const chartInstances = []
|
||||
|
||||
const _initChart = (dom) => {
|
||||
if (!dom) return null
|
||||
const existing = echarts.getInstanceByDom(dom)
|
||||
if (existing) existing.dispose()
|
||||
const chart = echarts.init(dom)
|
||||
chartInstances.push(chart)
|
||||
return chart
|
||||
}
|
||||
|
||||
const _resizeCharts = () => chartInstances.forEach(c => { try { c.resize() } catch {} })
|
||||
|
||||
const chartTheme = {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -59,70 +101,60 @@ const chartTheme = {
|
||||
}
|
||||
|
||||
const initTrendChart = async () => {
|
||||
const { data } = await getTrend(7)
|
||||
const chart = echarts.init(trendChart.value)
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['在线', '离线'],
|
||||
textStyle: { color: '#8a9ab8' },
|
||||
top: 4,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.date),
|
||||
axisLine: chartTheme.axisLine,
|
||||
axisTick: chartTheme.axisTick,
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
splitLine: chartTheme.splitLine,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '在线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.online),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#00d2b4', width: 2 },
|
||||
itemStyle: { color: '#00d2b4' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' },
|
||||
{ offset: 1, color: 'rgba(0,210,180,0.00)' },
|
||||
])
|
||||
},
|
||||
try {
|
||||
const { data } = await getTrend(7)
|
||||
if (!data || !data.length) return
|
||||
const chart = _initChart(trendChart.value)
|
||||
if (!chart) return
|
||||
chart.setOption({
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: '#8a9ab8', fontFamily: 'Noto Sans SC, sans-serif', fontSize: 12 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#141c30',
|
||||
borderColor: 'rgba(255,255,255,0.10)',
|
||||
textStyle: { color: '#e8edf5' },
|
||||
extraCssText: 'border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'
|
||||
},
|
||||
{
|
||||
name: '离线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.offline),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#ef4444', width: 2 },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' },
|
||||
{ offset: 1, color: 'rgba(239,68,68,0.00)' },
|
||||
])
|
||||
},
|
||||
legend: { data: ['在线', '离线'], textStyle: { color: '#8a9ab8' }, top: 4 },
|
||||
xAxis: {
|
||||
type: 'category', data: data.map(d => d.date),
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||
},
|
||||
],
|
||||
})
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '在线', type: 'line', data: data.map(d => d.online), smooth: true,
|
||||
symbol: 'circle', symbolSize: 5,
|
||||
lineStyle: { color: '#00d2b4', width: 2 }, itemStyle: { color: '#00d2b4' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' }, { offset: 1, color: 'rgba(0,210,180,0.00)' }
|
||||
])}
|
||||
},
|
||||
{
|
||||
name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true,
|
||||
symbol: 'circle', symbolSize: 5,
|
||||
lineStyle: { color: '#ef4444', width: 2 }, itemStyle: { color: '#ef4444' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' }, { offset: 1, color: 'rgba(239,68,68,0.00)' }
|
||||
])}
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Trend chart error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const initRegionChart = async () => {
|
||||
const { data } = await getByRegion()
|
||||
const chart = echarts.init(regionChart.value)
|
||||
const chart = _initChart(regionChart.value)
|
||||
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
@@ -154,9 +186,72 @@ const initRegionChart = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const initOltChart = async () => {
|
||||
const { data } = await getOltStats()
|
||||
const chart = _initChart(oltChart.value)
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
const ra = a.online / (a.total || 1), rb = b.online / (b.total || 1)
|
||||
return ra - rb
|
||||
})
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis', axisPointer: { type: 'shadow' },
|
||||
formatter: (ps) => {
|
||||
const d = ps[0]
|
||||
return `<b>${d.name}</b><br/>在线: ${d.data.online}/${d.data.total}<br/>在线率: ${(d.data.online/(d.data.total||1)*100).toFixed(1)}%<br/>离线: ${d.data.offline}`
|
||||
}
|
||||
},
|
||||
grid: { left: '3%', right: '8%', bottom: '3%', top: '8%', containLabel: true },
|
||||
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: sorted.map(d => d.name),
|
||||
axisLabel: { fontSize: 11, width: 120, overflow: 'truncate' },
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: sorted.map(d => ({
|
||||
name: d.name, value: +(d.online / (d.total || 1) * 100).toFixed(1),
|
||||
total: d.total, online: d.online, offline: d.offline,
|
||||
itemStyle: { color: d.online === 0 ? '#ef4444' : +(d.online/(d.total||1)*100).toFixed(1) < 70 ? '#f59e0b' : '#00d2b4',
|
||||
borderRadius: [0, 4, 4, 0] }
|
||||
})),
|
||||
barMaxWidth: 22,
|
||||
label: { show: true, position: 'right', fontSize: 11, color: '#8a9ab8', formatter: '{c}%' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
const initModelChart = async () => {
|
||||
const { data } = await getModelDistribution()
|
||||
if (!data.length) return
|
||||
const chart = _initChart(modelChart.value)
|
||||
const colors = ['#00d2b4','#3b82f6','#a855f7','#f59e0b','#22c55e','#ef4444','#ec4899','#6366f1','#14b8a6','#eab308']
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'item', formatter: '{b}: {c} 台 ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['42%','70%'], center: ['50%','50%'],
|
||||
data: data.map((d, i) => ({ value: d.count, name: d.model, itemStyle: { color: colors[i % colors.length] } })),
|
||||
label: { show: true, fontSize: 10, color: '#8a9ab8', formatter: '{b}\n{d}%' },
|
||||
labelLine: { length: 16, length2: 12 },
|
||||
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.3)' } },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initTrendChart()
|
||||
initRegionChart()
|
||||
initOltChart()
|
||||
initModelChart()
|
||||
window.addEventListener('resize', _resizeCharts)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', _resizeCharts)
|
||||
chartInstances.forEach(c => { try { c.dispose() } catch {} })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
<h1 class="page-title">统计概览</h1>
|
||||
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
||||
</div>
|
||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
<span v-if="loading">刷新中…</span>
|
||||
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
||||
<span v-else>刷新数据</span>
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
<span v-if="loading">刷新中…</span>
|
||||
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
||||
<span v-else>刷新数据</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
@@ -170,6 +172,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全离线学校(可折叠) -->
|
||||
<div v-if="offlineSchools.length > 0" class="offline-alert" :class="{ collapsed: offlineCollapsed }">
|
||||
<div class="offline-alert-header" @click="offlineCollapsed = !offlineCollapsed">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span>全离线学校({{ offlineSchools.length }} 所)</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="collapse-arrow" :class="{ rotated: !offlineCollapsed }">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!offlineCollapsed" class="offline-schools-list">
|
||||
<span v-for="s in offlineSchools" :key="s.school_name" class="offline-school-tag" @click="goToSchool(s.school_name)">
|
||||
{{ s.school_name }}({{ s.total }}台)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 乡镇详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="townVisible"
|
||||
@@ -197,9 +218,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../utils/request'
|
||||
import { getOfflineSchools } from '../api/stats'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
@@ -210,6 +232,8 @@ const data = ref({})
|
||||
const lastUpdated = ref('')
|
||||
const townVisible = ref(false)
|
||||
const selectedTown = ref(null)
|
||||
const offlineSchools = ref([])
|
||||
const offlineCollapsed = ref(true)
|
||||
|
||||
const rate = (item) => {
|
||||
if (!item || !item.total) return 0
|
||||
@@ -239,6 +263,13 @@ const goToSchool = (schoolName) => {
|
||||
router.push({ path: '/devices', query: { school_name: schoolName } })
|
||||
}
|
||||
|
||||
const loadOfflineSchools = async () => {
|
||||
try {
|
||||
const { data } = await getOfflineSchools()
|
||||
offlineSchools.value = data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -248,9 +279,24 @@ const loadData = async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loadOfflineSchools()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
let ws = null
|
||||
const connectWs = () => {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${proto}//${location.host}/api/ws/dashboard`)
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'check_complete') loadData()
|
||||
} catch {}
|
||||
}
|
||||
ws.onclose = () => { setTimeout(connectWs, 10000) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); connectWs() })
|
||||
onUnmounted(() => { if (ws) ws.close() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -286,6 +332,34 @@ onMounted(loadData)
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.report-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -352,6 +426,64 @@ onMounted(loadData)
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* 全离线学校告警 */
|
||||
.offline-alert {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.offline-alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.offline-alert.collapsed .offline-alert-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-arrow.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.offline-schools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.offline-school-tag {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.offline-school-tag:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
|
||||
@@ -1026,6 +1026,11 @@ const formatTime = (t) => fmtTimeRaw(t, { slice: 16 })
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.inventory-page { padding: 12px; }
|
||||
.summary-cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
|
||||
@@ -152,7 +152,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { padding: 24px; max-width: 1100px; }
|
||||
.page-wrap { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||
|
||||
@@ -99,6 +99,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业微信告警设置 -->
|
||||
<div class="settings-card" style="margin-top: 20px">
|
||||
<div class="card-header">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 8px">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
企业微信告警
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="setting-desc">当某个学校所有设备全部离线时,通过企业微信应用消息 API 发送告警。请填写企业微信自建应用的凭证信息。</p>
|
||||
<div class="wechat-field">
|
||||
<label class="wechat-label">CorpID</label>
|
||||
<input v-model="wechatCorpId" class="webhook-input" placeholder="企业ID" :disabled="wechatSaving" />
|
||||
</div>
|
||||
<div class="wechat-field">
|
||||
<label class="wechat-label">CorpSecret</label>
|
||||
<input v-model="wechatCorpSecret" type="password" class="webhook-input" placeholder="应用 Secret" :disabled="wechatSaving" />
|
||||
</div>
|
||||
<div class="wechat-field">
|
||||
<label class="wechat-label">AgentID</label>
|
||||
<input v-model="wechatAgentId" class="webhook-input" placeholder="应用 AgentID" :disabled="wechatSaving" />
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button class="save-btn" :disabled="wechatSaving" @click="saveWebhook">
|
||||
<svg v-if="wechatSaving" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ wechatSaving ? '保存中…' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -179,6 +214,7 @@ const save = async () => {
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadAbout()
|
||||
loadWebhook()
|
||||
const timer = setInterval(load, 10000)
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
})
|
||||
@@ -204,12 +240,43 @@ const saveAbout = async () => {
|
||||
aboutSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const wechatCorpId = ref('')
|
||||
const wechatCorpSecret = ref('')
|
||||
const wechatAgentId = ref('')
|
||||
const wechatSaving = ref(false)
|
||||
|
||||
const loadWebhook = async () => {
|
||||
try {
|
||||
const { data } = await getSettings()
|
||||
const setVal = (key, ref) => { const s = data?.[key]; if (s) ref.value = s.value || '' }
|
||||
setVal('wechat_corpid', wechatCorpId)
|
||||
setVal('wechat_corpsecret', wechatCorpSecret)
|
||||
setVal('wechat_agentid', wechatAgentId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveWebhook = async () => {
|
||||
wechatSaving.value = true
|
||||
try {
|
||||
await request.put('/settings/webhook', {
|
||||
corpid: wechatCorpId.value,
|
||||
corpsecret: wechatCorpSecret.value,
|
||||
agentid: wechatAgentId.value,
|
||||
})
|
||||
ElMessage.success('企业微信配置已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
wechatSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
padding: 24px;
|
||||
max-width: 640px;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -427,6 +494,54 @@ const saveAbout = async () => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webhook-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.webhook-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.webhook-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wechat-section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.wechat-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.wechat-label {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.settings-page {
|
||||
padding: 16px 12px;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>姓名</th>
|
||||
<th>角色</th>
|
||||
<th>区域/学校</th>
|
||||
<th>状态</th>
|
||||
@@ -38,7 +38,7 @@
|
||||
</tr>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="td-username">{{ u.username }}</td>
|
||||
<td class="td-muted">{{ u.email || '—' }}</td>
|
||||
<td class="td-muted">{{ u.display_name || u.username || '—' }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
||||
</td>
|
||||
@@ -81,7 +81,7 @@
|
||||
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">编辑用户:{{ editUser.username }}</span>
|
||||
<span class="modal-title">编辑用户:{{ editUser.display_name || editUser.username }}</span>
|
||||
<button class="modal-close" @click="editUser = null">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -330,7 +330,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { padding: 24px; max-width: 1200px; }
|
||||
.page-wrap { padding: 24px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.header-filters { display: flex; gap: 10px; }
|
||||
|
||||
@@ -11,6 +11,10 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: (process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001').replace('http', 'ws'),
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user