From e2333ea9b89f9df7da907a9c661fb02aa80f8636 Mon Sep 17 00:00:00 2001 From: YueL1331 <358700404@qq.com> Date: Mon, 12 Jan 2026 15:57:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 2_1banben/app.py | 2 +- 2_1banben/routes/api.py | 202 +++++++++- .../src/views/Dashboard.vue | 364 ++++++++++-------- 3 files changed, 399 insertions(+), 169 deletions(-) diff --git a/2_1banben/app.py b/2_1banben/app.py index 228d884..c2eb23d 100644 --- a/2_1banben/app.py +++ b/2_1banben/app.py @@ -156,7 +156,7 @@ def create_app(): func=auto_monitor_job, args=[app], trigger='cron', - hour=10, + hour=12, minute=0 ) diff --git a/2_1banben/routes/api.py b/2_1banben/routes/api.py index 6e9f6ee..c850042 100644 --- a/2_1banben/routes/api.py +++ b/2_1banben/routes/api.py @@ -18,7 +18,131 @@ api_bp = Blueprint('api', __name__, url_prefix='/api') # ======================= -# 0. 认证接口 +# 0. 核心算法:数据质量分析 (含夜间免打扰) +# ======================= +def check_data_quality(content_data, source_type, data_time_str=None): + """ + 在后端快速分析数据质量 + :param content_data: 已解析的 JSON 对象 (Dict 或 String) + :param source_type: 设备类型源字符串 (区分 106 或 82) + :param data_time_str: 数据生成时间字符串 (用于判断是否为夜晚) + :return: 'ok' | 'warning' | 'error' + """ + if not content_data: + return 'ok' + + # --- [夜间免打扰逻辑] --- + # 物理规律:晚上没有太阳,光谱仪数值低是正常的,不应报错。 + # 逻辑:只有在 08:00 - 17:00 之间才检查数值。 + if data_time_str and data_time_str != 'N/A': + try: + # 1. 格式清洗 + clean_time = str(data_time_str).replace('_', '-') + + # 2. 尝试解析时间 + dt = None + try: + dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M:%S") + except ValueError: + try: + dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M") + except ValueError: + pass + + # 3. 如果解析成功,判断小时数 + if dt: + start_hour = 8 # 早上 8 点 + end_hour = 17 # 下午 5 点 + + # 如果当前时间 小于8点 或者 大于等于17点,视为夜晚,直接返回正常 + if dt.hour < start_hour or dt.hour >= end_hour: + return 'ok' + + except Exception: + pass + # --------------------------- + + status = 'ok' + source_str = str(source_type) + + # === 106 设备逻辑 (CSV 格式) === + if '106' in source_str: + try: + text_content = "" + if isinstance(content_data, dict): + text_content = content_data.get('content', str(content_data)) + else: + text_content = str(content_data) + + lines = text_content.split('\n') + for line in lines: + if 'OSIFBeta' not in line: continue + + parts = line.split(',') + if len(parts) < 10: continue + + try: + int_time = int(parts[2]) + except: + continue + + # 只有积分时间饱和 (>= 66534) 才检查数值 + if int_time >= 66534: + data_points = [] + for p in parts[3:]: + try: + data_points.append(float(p)) + except: + pass + + if not data_points: continue + + # 规则1:红色报错 (存在 < 100 的点) + for val in data_points: + if val < 100: + return 'error' + + # 规则2:黄色警告 (连续 5 个点在 100-500 之间) + consecutive_warning = 0 + for val in data_points: + if 100 <= val <= 500: + consecutive_warning += 1 + if consecutive_warning >= 5: + status = 'warning' + else: + consecutive_warning = 0 + return status + + except Exception: + return 'ok' + + # === 82 设备逻辑 (JSON 格式) === + else: + try: + if not isinstance(content_data, dict): + return 'ok' + specs = content_data.get('downspec', []) + if not specs: + specs = content_data.get('upspec', []) + + if not specs: return 'ok' + + consecutive_low = 0 + for val in specs: + if not isinstance(val, (int, float)): continue + if val < 500: + consecutive_low += 1 + if consecutive_low >= 2: + return 'error' + else: + consecutive_low = 0 + return 'ok' + except Exception: + return 'ok' + + +# ======================= +# 1. 认证接口 # ======================= @api_bp.route('/login', methods=['POST']) @@ -39,16 +163,32 @@ def login(): # ======================= -# 1. 设备概览与详情接口 +# 2. 设备概览与详情接口 # ======================= @api_bp.route('/devices_overview', methods=['GET']) def devices_overview(): try: devices = Device.query.all() - data_list = [d.to_dict() for d in devices] + data_list = [] + + for d in devices: + item = d.to_dict() + parsed_content = None + if d.json_data: + try: + parsed_content = json.loads(d.json_data) + except: + parsed_content = None + + # 传入 d.latest_time 以启用夜间判断 + quality_status = check_data_quality(parsed_content, d.source, d.latest_time) + item['data_quality'] = quality_status + data_list.append(item) + return jsonify({'code': 200, 'data': data_list}) except Exception as e: + print(f"Overview Error: {e}") return jsonify({'code': 500, 'message': str(e)}) @@ -86,7 +226,7 @@ def device_data_by_date(): # ======================= -# 2. 维修日志接口 +# 3. 维修日志接口 # ======================= @api_bp.route('/logs/list', methods=['GET']) @@ -166,7 +306,7 @@ def delete_log(): # ======================= -# 3. 辅助与控制接口 (核心修复逻辑) +# 4. 辅助与控制接口 # ======================= def calculate_offset(latest_time_str): @@ -201,7 +341,6 @@ def run_monitor(): source = item.get('source', '') target_time = item.get('target_time') - # 处理 106 路径时间 if '106' in str(source): try: path_str = d_raw.get('path', '') @@ -213,15 +352,12 @@ def run_monitor(): json_str = json.dumps(d_raw, ensure_ascii=False) if isinstance(d_raw, (dict, list)) else str(d_raw) - # --- 关键修改:先查询,后更新 --- device = Device.query.filter_by(name=d_name).first() if not device: - # 只有新设备才初始化静态字段 device = Device(name=d_name, source=source, install_site="") db.session.add(device) - db.session.flush() # 获取 ID 供 History 使用 + db.session.flush() - # 仅更新动态抓取的字段,保留手动填写的 install_site, is_maintaining, is_hidden device.status = item.get('status') device.current_value = item.get('value') device.latest_time = target_time @@ -276,4 +412,48 @@ def toggle_hidden(): device.is_hidden = data.get('is_hidden') db.session.commit() return jsonify({'code': 200}) - return jsonify({'code': 404}), 404 \ No newline at end of file + return jsonify({'code': 404}), 404 + + +# ======================= +# 5. 手动添加设备接口 (新增) +# ======================= +@api_bp.route('/add_device', methods=['POST']) +def add_device(): + data = request.get_json() + name = data.get('name') + site = data.get('site', '') + + if not name: + return jsonify({'code': 400, 'message': '必须填写设备名称'}), 400 + + # 1. 检查是否已存在 + existing = Device.query.filter_by(name=name).first() + if existing: + return jsonify({'code': 400, 'message': f'设备 {name} 已存在,无需重复添加'}), 400 + + try: + # 2. 创建新设备记录 + # source 标记为 'manual',方便以后区分 + # status 默认为 'offline' (离线) + # latest_time 默认为 'N/A' + new_device = Device( + name=name, + install_site=site, + source='manual', + status='offline', + current_value='0', + latest_time='N/A', + check_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + json_data='{}', + is_hidden=0, + is_maintaining=0 + ) + + db.session.add(new_device) + db.session.commit() + + return jsonify({'code': 200, 'message': '设备添加成功'}) + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': str(e)}) \ No newline at end of file diff --git a/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue b/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue index 0bdf1ec..68e228a 100644 --- a/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue +++ b/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue @@ -13,37 +13,35 @@
- - 日志 - - - 检测 - + 新增 + + 日志 + 检测 -
- - - 退出 - + 退出
- 离线/>7天 - 滞后1-7天 - 滞后24h + 离线/严重滞后 + 滞后 (>24h) + 数据异常/昨日 正常
- - 全部 + + 全部({{ summary.totalCount }}) + - 异常({{ summary.errorCount + summary.warningCount }}) + 状态异常({{ summary.errorCount + summary.warningCount }}) + + + 数据异常({{ summary.dataErrorCount }}) 回收({{ summary.hiddenCount }}) @@ -118,16 +116,48 @@ - + @@ -162,6 +192,26 @@ + + + + + + + + + + + + +
@@ -170,9 +220,8 @@ import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'v import { useRouter } from 'vue-router' import axios from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' -import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton } from '@element-plus/icons-vue' +import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Warning, WarningFilled, Plus } from '@element-plus/icons-vue' -// 引入子组件 import DataMonitor from './DataMonitor.vue' import MaintenanceLogs from './MaintenanceLogs.vue' @@ -184,37 +233,35 @@ const lastCheckTime = ref('') const windowHeight = ref(window.innerHeight) const windowWidth = ref(window.innerWidth) -// 计算表格高度:手机端预留更多空间给折行的头部 const tableHeight = computed(() => { const isMobile = windowWidth.value < 768 - // 手机端头部元素堆叠,需要减去更多的高度 const offset = isMobile ? 380 : 250 return windowHeight.value - offset }) const filters = reactive({ status: 'all', keyword: '' }) const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : '' - const dataMonitorRef = ref(null) const maintenanceLogsRef = ref(null) +// === 添加设备相关变量 === +const showAddDialog = ref(false) +const isAdding = ref(false) +const newDeviceForm = reactive({ name: '', site: '' }) + +// === 统计逻辑 (修改:增加了 totalCount) === const summary = computed(() => { - const activeDevices = rawData.value.filter(r => !r.is_hidden) - const errors = activeDevices.filter(r => r.statusType === 'error').length - const warnings = activeDevices.filter(r => r.statusType === 'warning').length - const hidden = rawData.value.filter(r => r.is_hidden).length - return { errorCount: errors, warningCount: warnings, hiddenCount: hidden } + const active = rawData.value.filter(r => !r.is_hidden) + return { + totalCount: active.length, // [新增] 统计所有非隐藏设备数量 + errorCount: active.filter(r => r.statusType === 'error').length, + warningCount: active.filter(r => r.statusType === 'warning').length, + hiddenCount: rawData.value.filter(r => r.is_hidden).length, + dataErrorCount: active.filter(r => r.data_quality === 'error' || r.data_quality === 'warning').length + } }) -const handleLogout = () => { - ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => { - localStorage.removeItem('isLoggedIn') - localStorage.removeItem('token') - router.push('/') - ElMessage.success('已安全退出') - }).catch(() => {}) -} - +// === 数据获取逻辑 === const fetchData = async () => { loading.value = true try { @@ -222,54 +269,109 @@ const fetchData = async () => { const backendList = res.data.data || res.data const now = new Date() - let processedData = backendList.map(item => { + rawData.value = backendList.map(item => { const isHidden = item.is_hidden === true || item.is_hidden === 1 let diffDays = 0, diffHours = 0, isToday = false, validTime = false + // === 1. 时间计算逻辑 === if (item.latest_time && item.latest_time !== 'N/A') { const cleanDateStr = item.latest_time.toString().replace(/_/g, '-') const d = new Date(cleanDateStr) if (!isNaN(d.getTime())) { validTime = true - const diffTime = now - d - const safeDiff = diffTime > 0 ? diffTime : 0 - diffHours = safeDiff / (1000 * 60 * 60) - diffDays = safeDiff / (1000 * 60 * 60 * 24) isToday = d.toDateString() === now.toDateString() + const diff = now - d + diffHours = (diff > 0 ? diff : 0) / (1000 * 3600) + diffDays = diffHours / 24 } } - let sortHours = diffHours; - if (item.is_maintaining) sortHours = Number.MAX_SAFE_INTEGER; - else if (item.status === 'offline' || item.status === '已离线') sortHours = 1000000000; - else if (!validTime) sortHours = 500000000; + // === 2. 排序权重计算 === + let sortHours = diffHours + if (item.is_maintaining) sortHours = Number.MAX_SAFE_INTEGER + else if (item.status === 'offline' || item.status === '已离线') sortHours = 1000000000 + else if (!validTime) sortHours = 500000000 + // === 3. 状态分类逻辑 === let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff' + if (item.is_maintaining) { statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance'; - } else if ((item.status === 'offline' || item.status === '已离线') || (!validTime || diffDays > 7)) { - statusLabel = '离线/滞后'; statusColor = '#F56C6C'; statusType = 'error'; + } else if ((item.status === 'offline' || item.status === '已离线')) { + statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error'; + } else if (!validTime || diffDays > 7) { + statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error'; } else if (diffHours > 24) { - statusColor = '#E6A23C'; statusLabel = '数据滞后'; statusType = 'warning'; + statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning'; } else if (!isToday) { - statusColor = '#FAC858'; statusLabel = '昨日数据'; statusType = 'slight-warning'; statusLabelColor = '#333'; + statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333'; } return { - ...item, is_hidden: isHidden, diffDays, diffHours, sortHours, isToday, - statusColor, statusLabel, statusType, statusLabelColor, isEditingSite: false, tempSite: '' + ...item, + is_hidden: isHidden, + diffDays, diffHours, sortHours, isToday, + statusColor, statusLabel, statusType, statusLabelColor, + isEditingSite: false, tempSite: '', + data_quality: item.data_quality || 'ok' } - }) - processedData.sort((a, b) => b.sortHours - a.sortHours) - rawData.value = processedData + }).sort((a, b) => b.sortHours - a.sortHours) + lastCheckTime.value = new Date().toLocaleString() } catch (e) { ElMessage.error('获取数据失败') + console.error(e) } finally { loading.value = false } } +// === 筛选逻辑 === +const filteredData = computed(() => { + return rawData.value.filter(item => { + if (filters.status === 'hidden') return item.is_hidden + if (item.is_hidden) return false + + if (filters.status === 'abnormal') { + return (item.statusType === 'error' || item.statusType === 'warning' || item.statusType === 'slight-warning') + } + if (filters.status === 'data_error') { + return (item.data_quality === 'error' || item.data_quality === 'warning') + } + return true + }).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase())) +}) + +// === 提交新设备逻辑 === +const handleAddDeviceSubmit = async () => { + if (!newDeviceForm.name) { + ElMessage.warning('请填写设备名称') + return + } + isAdding.value = true + try { + const res = await axios.post(`${API_BASE}/api/add_device`, { + name: newDeviceForm.name, + site: newDeviceForm.site + }) + ElMessage.success(res.data.message) + showAddDialog.value = false + + // 清空表单 + newDeviceForm.name = '' + newDeviceForm.site = '' + + // 刷新列表 + fetchData() + } catch (error) { + const msg = error.response?.data?.message || '添加失败' + ElMessage.error(msg) + } finally { + isAdding.value = false + } +} + +// === 辅助功能函数 === const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) } const openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) } const runManualMonitor = async () => { @@ -281,152 +383,100 @@ const runManualMonitor = async () => { } catch (e) { ElMessage.warning('请求频繁') } finally { setTimeout(() => { runningTask.value = false }, 1000) } } - -const filteredData = computed(() => { - return rawData.value.filter(item => { - if (filters.status === 'hidden') return item.is_hidden - if (item.is_hidden) return false - if (filters.status === 'abnormal') return (item.statusType === 'error' || item.statusType === 'warning' || item.statusType === 'slight-warning') - return true - }).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase())) -}) - const handleEditSite = (row) => { row.tempSite = row.install_site; row.isEditingSite = true - nextTick(() => { - // 兼容性查找 input - const inputs = document.querySelectorAll('.site-input-inner input') - if (inputs.length > 0) inputs[inputs.length - 1].focus() - }) + nextTick(() => { const inputs = document.querySelectorAll('.site-input-inner input'); if(inputs.length) inputs[inputs.length-1].focus() }) } - const saveSite = async (row) => { if (!row.isEditingSite) return const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false if (oldVal === row.tempSite) return - try { - await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: row.tempSite }) - ElMessage.success('已更新') - } catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') } + try { await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: row.tempSite }); ElMessage.success('已更新') } + catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') } } - const handleMaintenanceBeforeChange = (row) => { return new Promise((resolve) => { const newVal = !row.is_maintaining axios.post(`${API_BASE}/api/toggle_maintenance`, { name: row.name, is_maintaining: newVal }) - .then(() => { row.is_maintaining = newVal; fetchData(); ElMessage.success(newVal ? '已进入维修模式' : '已恢复'); resolve(true) }) - .catch(() => { ElMessage.error('操作失败'); resolve(false) }) + .then(() => { row.is_maintaining = newVal; fetchData(); resolve(true) }) + .catch(() => { resolve(false) }) }) } - -const toggleHidden = async (row, targetState) => { - try { - await axios.post(`${API_BASE}/api/toggle_hidden`, { name: row.name, is_hidden: targetState }) - row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复') - } catch (e) { ElMessage.error('操作失败') } +const toggleHidden = async (row, val) => { + try { await axios.post(`${API_BASE}/api/toggle_hidden`, { name: row.name, is_hidden: val }); row.is_hidden = val; fetchData(); } + catch (e) { ElMessage.error('操作失败') } +} +const handleLogout = () => { + ElMessageBox.confirm('确定退出?', '提示', { type: 'warning' }).then(() => { localStorage.removeItem('token'); router.push('/'); }).catch(() => {}) } - const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : '' + +// 行样式计算 const tableRowClassName = ({ row }) => { if (row.is_hidden) return 'hidden-row' - if (row.statusType === 'maintenance') return 'maintenance-row' + if (row.data_quality === 'error') return 'data-error-row' if (row.statusType === 'error') return 'error-row' + if (row.data_quality === 'warning') return 'data-warning-row' if (row.statusType === 'warning') return 'warning-row' + if (row.statusType === 'maintenance') return 'maintenance-row' return '' } - -const updateDimensions = () => { - windowHeight.value = window.innerHeight - windowWidth.value = window.innerWidth -} - -onMounted(() => { - fetchData() - window.addEventListener('resize', updateDimensions) -}) +const updateDimensions = () => { windowHeight.value = window.innerHeight; windowWidth.value = window.innerWidth } +onMounted(() => { fetchData(); window.addEventListener('resize', updateDimensions) }) onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))