Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51deee1493 | |||
| f167bbc2f2 | |||
| 4cb503089e | |||
| fb52536898 | |||
| e093ae9633 | |||
| 195c3f8fa4 | |||
| cb567b2c7d | |||
| 9b7799b827 | |||
| f043983d24 | |||
| 9ebfd79414 | |||
| fe21532741 | |||
| e2333ea9b8 | |||
| 43f049112f | |||
| ffbd494b7b | |||
| ca895af384 | |||
| e67edec876 | |||
| 4f970967e9 | |||
| f527faa06e | |||
| 29b48f6ba4 | |||
| a8984a156c | |||
| a5b0b71d26 | |||
| af4b4a28c3 | |||
| 3099427eb6 | |||
| ef440177b3 | |||
| 15d66d6694 | |||
| cbe6e884b5 | |||
| fa66da3ff5 | |||
| 2c2f9e43e3 | |||
| 45c3d602c0 | |||
| e36b68da2e | |||
| 19e34ec065 | |||
| e9c9c60b27 | |||
| db2c040e5b | |||
| 776559b6eb | |||
| 1f85bbbc2e |
12
.idea/ZDXX.iml
generated
Normal file
12
.idea/ZDXX.iml
generated
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (Learn-Web-spider)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
</module>
|
||||
38
1.1/dabao/MonitorSystem.spec
Normal file
38
1.1/dabao/MonitorSystem.spec
Normal file
@ -0,0 +1,38 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['test1.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('dist', 'dist')],
|
||||
hiddenimports=['flask_sqlalchemy', 'lxml'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='MonitorSystem',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
5979
1.1/dabao/build/MonitorSystem/Analysis-00.toc
Normal file
5979
1.1/dabao/build/MonitorSystem/Analysis-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
2342
1.1/dabao/build/MonitorSystem/EXE-00.toc
Normal file
2342
1.1/dabao/build/MonitorSystem/EXE-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
1.1/dabao/build/MonitorSystem/MonitorSystem.pkg
Normal file
BIN
1.1/dabao/build/MonitorSystem/MonitorSystem.pkg
Normal file
Binary file not shown.
2320
1.1/dabao/build/MonitorSystem/PKG-00.toc
Normal file
2320
1.1/dabao/build/MonitorSystem/PKG-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
1.1/dabao/build/MonitorSystem/PYZ-00.pyz
Normal file
BIN
1.1/dabao/build/MonitorSystem/PYZ-00.pyz
Normal file
Binary file not shown.
3277
1.1/dabao/build/MonitorSystem/PYZ-00.toc
Normal file
3277
1.1/dabao/build/MonitorSystem/PYZ-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
1.1/dabao/build/MonitorSystem/base_library.zip
Normal file
BIN
1.1/dabao/build/MonitorSystem/base_library.zip
Normal file
Binary file not shown.
131
1.1/dabao/build/MonitorSystem/warn-MonitorSystem.txt
Normal file
131
1.1/dabao/build/MonitorSystem/warn-MonitorSystem.txt
Normal file
@ -0,0 +1,131 @@
|
||||
|
||||
This file lists modules PyInstaller was not able to find. This does not
|
||||
necessarily mean this module is required for running your program. Python and
|
||||
Python 3rd-party packages include a lot of conditional or optional modules. For
|
||||
example the module 'ntpath' only exists on Windows, whereas the module
|
||||
'posixpath' only exists on Posix systems.
|
||||
|
||||
Types if import:
|
||||
* top-level: imported at the top-level - look at these first
|
||||
* conditional: imported within an if-statement
|
||||
* delayed: imported within a function
|
||||
* optional: imported within a try-except-statement
|
||||
|
||||
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
|
||||
tracking down the missing module yourself. Thanks!
|
||||
|
||||
missing module named pyimod02_importers - imported by D:\Git\Learn-Web-spider\venv\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed), D:\Git\Learn-Web-spider\venv\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgres.py (delayed)
|
||||
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed), http.server (delayed, optional), webbrowser (delayed), distutils.util (delayed, conditional, optional), setuptools._vendor.backports.tarfile (optional), distutils.archive_util (optional), setuptools._distutils.util (delayed, conditional, optional), setuptools._distutils.archive_util (optional)
|
||||
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), setuptools._vendor.backports.tarfile (optional), distutils.archive_util (optional), setuptools._distutils.archive_util (optional)
|
||||
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
|
||||
missing module named fcntl - imported by subprocess (optional), tornado.platform.posix (top-level)
|
||||
missing module named org - imported by copy (optional)
|
||||
missing module named urllib.urlopen - imported by urllib (delayed, optional), lxml.html (delayed, optional)
|
||||
missing module named urllib.urlencode - imported by urllib (delayed, optional), lxml.html (delayed, optional)
|
||||
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional)
|
||||
missing module named resource - imported by posix (top-level)
|
||||
missing module named _manylinux - imported by packaging._manylinux (delayed, optional), setuptools._vendor.packaging._manylinux (delayed, optional), wheel.vendored.packaging._manylinux (delayed, optional)
|
||||
missing module named '_typeshed.importlib' - imported by pkg_resources (conditional)
|
||||
missing module named _typeshed - imported by werkzeug._internal (conditional), pkg_resources (conditional), setuptools.glob (conditional), setuptools.compat.py311 (conditional), setuptools._vendor.jaraco.collections (conditional)
|
||||
missing module named jnius - imported by setuptools._vendor.platformdirs.android (delayed, conditional, optional)
|
||||
missing module named android - imported by setuptools._vendor.platformdirs.android (delayed, conditional, optional)
|
||||
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
|
||||
missing module named 'distutils._modified' - imported by setuptools._distutils.file_util (delayed)
|
||||
missing module named 'distutils._log' - imported by setuptools._distutils.command.bdist_dumb (top-level), setuptools._distutils.command.bdist_rpm (top-level), setuptools._distutils.command.build_clib (top-level), setuptools._distutils.command.build_ext (top-level), setuptools._distutils.command.build_py (top-level), setuptools._distutils.command.build_scripts (top-level), setuptools._distutils.command.clean (top-level), setuptools._distutils.command.config (top-level), setuptools._distutils.command.install (top-level), setuptools._distutils.command.install_scripts (top-level), setuptools._distutils.command.sdist (top-level)
|
||||
missing module named usercustomize - imported by site (delayed, optional)
|
||||
missing module named sitecustomize - imported by site (delayed, optional)
|
||||
missing module named readline - imported by code (delayed, conditional, optional), flask.cli (delayed, conditional, optional), rlcompleter (optional), cmd (delayed, conditional, optional), pdb (delayed, optional), site (delayed, optional)
|
||||
missing module named _scproxy - imported by urllib.request (conditional)
|
||||
missing module named termios - imported by getpass (optional), tty (top-level), werkzeug._reloader (delayed, optional), click._termui_impl (conditional)
|
||||
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
|
||||
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
|
||||
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
|
||||
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
|
||||
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
|
||||
missing module named 'org.python' - imported by pickle (optional), xml.sax (delayed, conditional)
|
||||
missing module named 'java.lang' - imported by platform (delayed, optional), xml.sax._exceptions (conditional)
|
||||
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
|
||||
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
|
||||
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
|
||||
missing module named trove_classifiers - imported by setuptools.config._validate_pyproject.formats (optional)
|
||||
missing module named importlib_resources - imported by setuptools._vendor.jaraco.text (optional)
|
||||
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
|
||||
missing module named vms_lib - imported by platform (delayed, optional)
|
||||
missing module named java - imported by platform (delayed)
|
||||
missing module named _winreg - imported by platform (delayed, optional), tzlocal.win32 (optional)
|
||||
missing module named collections.MutableMapping - imported by collections (optional), html5lib.treebuilders.dom (optional), html5lib.treebuilders.etree_lxml (optional)
|
||||
missing module named collections.Mapping - imported by collections (optional), html5lib._utils (optional), html5lib._trie._base (optional)
|
||||
missing module named collections.Callable - imported by collections (optional), socks (optional), bs4.element (optional), bs4.builder._lxml (optional)
|
||||
missing module named htmlentitydefs - imported by tornado.escape (conditional), lxml.html.soupparser (optional)
|
||||
missing module named BeautifulSoup - imported by lxml.html.soupparser (optional)
|
||||
missing module named cchardet - imported by bs4.dammit (optional)
|
||||
missing module named bs4.builder.HTMLParserTreeBuilder - imported by bs4.builder (top-level), bs4 (top-level)
|
||||
missing module named StringIO - imported by six (conditional)
|
||||
missing module named html5lib.treebuilders._base - imported by html5lib.treebuilders (optional), bs4.builder._html5lib (optional), lxml.html._html5builder (top-level)
|
||||
missing module named html5lib.XHTMLParser - imported by html5lib (optional), lxml.html.html5parser (optional)
|
||||
runtime module named six.moves - imported by dateutil.tz.tz (top-level), dateutil.tz._factories (top-level), dateutil.tz.win (top-level), dateutil.rrule (top-level), html5lib._inputstream (top-level), six.moves.urllib (top-level), html5lib.filters.sanitizer (top-level)
|
||||
missing module named six.moves.range - imported by six.moves (top-level), dateutil.rrule (top-level)
|
||||
missing module named 'genshi.core' - imported by html5lib.treewalkers.genshi (top-level)
|
||||
missing module named genshi - imported by html5lib.treewalkers.genshi (top-level)
|
||||
missing module named urlparse - imported by tornado.escape (conditional), lxml.ElementInclude (optional), lxml.html.html5parser (optional)
|
||||
missing module named urllib2 - imported by lxml.ElementInclude (optional), lxml.html.html5parser (optional)
|
||||
missing module named lxml_html_clean - imported by lxml.html.clean (optional)
|
||||
missing module named twisted - imported by apscheduler.schedulers.twisted (optional)
|
||||
missing module named trollius - imported by tornado.platform.asyncio (optional)
|
||||
missing module named __builtin__ - imported by tornado.gen (conditional)
|
||||
missing module named backports_abc - imported by tornado.gen (optional)
|
||||
missing module named singledispatch - imported by tornado.gen (optional)
|
||||
missing module named thread - imported by tornado.ioloop (conditional)
|
||||
missing module named 'tornado.speedups' - imported by tornado.util (conditional, optional)
|
||||
missing module named monotonic - imported by tornado.platform.auto (optional)
|
||||
missing module named monotime - imported by tornado.platform.auto (optional)
|
||||
missing module named _curses - imported by curses (top-level), curses.has_key (top-level)
|
||||
missing module named win32evtlog - imported by logging.handlers (delayed, optional)
|
||||
missing module named win32evtlogutil - imported by logging.handlers (delayed, optional)
|
||||
missing module named 'gevent.lock' - imported by apscheduler.schedulers.gevent (optional)
|
||||
missing module named 'gevent.event' - imported by apscheduler.schedulers.gevent (optional)
|
||||
missing module named gevent - imported by apscheduler.executors.gevent (optional), apscheduler.schedulers.gevent (optional)
|
||||
missing module named 'kazoo.client' - imported by apscheduler.jobstores.zookeeper (optional)
|
||||
missing module named kazoo - imported by apscheduler.jobstores.zookeeper (top-level)
|
||||
missing module named annotationlib - imported by sqlalchemy.util.langhelpers (conditional)
|
||||
missing module named pysqlcipher3 - imported by sqlalchemy.dialects.sqlite.pysqlcipher (delayed)
|
||||
missing module named sqlcipher3 - imported by sqlalchemy.dialects.sqlite.pysqlcipher (delayed, optional)
|
||||
missing module named psycopg2 - imported by sqlalchemy.dialects.postgresql.psycopg2 (delayed)
|
||||
missing module named 'psycopg.pq' - imported by sqlalchemy.dialects.postgresql.psycopg (delayed)
|
||||
missing module named 'psycopg.types' - imported by sqlalchemy.dialects.postgresql.psycopg (delayed, conditional)
|
||||
missing module named 'psycopg.adapt' - imported by sqlalchemy.dialects.postgresql.psycopg (delayed, conditional)
|
||||
missing module named psycopg - imported by sqlalchemy.dialects.postgresql.psycopg (delayed, conditional)
|
||||
missing module named asyncpg - imported by sqlalchemy.dialects.postgresql.asyncpg (delayed)
|
||||
missing module named oracledb - imported by sqlalchemy.dialects.oracle.oracledb (delayed, conditional)
|
||||
missing module named cx_Oracle - imported by sqlalchemy.dialects.oracle.cx_oracle (delayed)
|
||||
missing module named mysql - imported by sqlalchemy.dialects.mysql.mysqlconnector (delayed, conditional, optional)
|
||||
missing module named asyncmy - imported by sqlalchemy.dialects.mysql.asyncmy (delayed)
|
||||
missing module named nacl - imported by pymysql._auth (delayed, optional)
|
||||
missing module named 'cryptography.hazmat' - imported by werkzeug.serving (delayed, conditional, optional), redis.ocsp (top-level), pymysql._auth (optional)
|
||||
missing module named rethinkdb - imported by apscheduler.jobstores.rethinkdb (optional)
|
||||
missing module named cryptography - imported by urllib3.contrib.pyopenssl (top-level), requests (conditional, optional), werkzeug.serving (delayed, optional), flask.cli (delayed, conditional, optional), redis.utils (optional), redis.ocsp (top-level)
|
||||
missing module named hiredis - imported by redis.utils (optional), redis.connection (conditional), redis._parsers.hiredis (delayed)
|
||||
missing module named 'cryptography.x509' - imported by urllib3.contrib.pyopenssl (delayed, optional), werkzeug.serving (delayed, conditional, optional), redis.ocsp (top-level)
|
||||
missing module named 'cryptography.exceptions' - imported by redis.ocsp (top-level)
|
||||
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level), redis.connection (delayed, conditional)
|
||||
missing module named 'pymongo.errors' - imported by apscheduler.jobstores.mongodb (optional)
|
||||
missing module named pymongo - imported by apscheduler.jobstores.mongodb (optional)
|
||||
missing module named bson - imported by apscheduler.jobstores.mongodb (optional)
|
||||
missing module named etcd3 - imported by apscheduler.jobstores.etcd (optional)
|
||||
missing module named 'setuptools._vendor.backports.zoneinfo' - imported by apscheduler.util (conditional)
|
||||
missing module named dateutil.tz.tzfile - imported by dateutil.tz (top-level), dateutil.zoneinfo (top-level)
|
||||
missing module named asgiref - imported by flask.app (delayed, optional)
|
||||
missing module named '_typeshed.wsgi' - imported by werkzeug.exceptions (conditional), werkzeug.http (conditional), werkzeug.wsgi (conditional), werkzeug.utils (conditional), werkzeug.wrappers.response (conditional), werkzeug.test (conditional), werkzeug.formparser (conditional), werkzeug.wrappers.request (conditional), werkzeug.serving (conditional), werkzeug.debug (conditional), werkzeug.middleware.shared_data (conditional), werkzeug.local (conditional), werkzeug.routing.exceptions (conditional), werkzeug.routing.map (conditional), flask.typing (conditional), flask.ctx (conditional), flask.testing (conditional), flask.cli (conditional), flask.app (conditional)
|
||||
missing module named dotenv - imported by flask.cli (delayed, optional)
|
||||
missing module named 'watchdog.observers' - imported by werkzeug._reloader (delayed)
|
||||
missing module named 'watchdog.events' - imported by werkzeug._reloader (delayed)
|
||||
missing module named watchdog - imported by werkzeug._reloader (delayed)
|
||||
missing module named simplejson - imported by requests.compat (conditional, optional)
|
||||
missing module named dummy_threading - imported by requests.cookies (optional)
|
||||
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
|
||||
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
|
||||
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
|
||||
missing module named win_inet_pton - imported by socks (conditional, optional)
|
||||
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
|
||||
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
|
||||
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
|
||||
44995
1.1/dabao/build/MonitorSystem/xref-MonitorSystem.html
Normal file
44995
1.1/dabao/build/MonitorSystem/xref-MonitorSystem.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
1.1/dabao/dist/MonitorSystem.exe
vendored
Normal file
BIN
1.1/dabao/dist/MonitorSystem.exe
vendored
Normal file
Binary file not shown.
155
1.1/dabao/dist/assets/index-0f069df0.js
vendored
Normal file
155
1.1/dabao/dist/assets/index-0f069df0.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
1.1/dabao/dist/assets/index-c85ab497.css
vendored
Normal file
1
1.1/dabao/dist/assets/index-c85ab497.css
vendored
Normal file
File diff suppressed because one or more lines are too long
15
1.1/dabao/dist/index.html
vendored
Normal file
15
1.1/dabao/dist/index.html
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>my-vue-app</title>
|
||||
<script type="module" crossorigin src="./assets/index-0f069df0.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index-c85ab497.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1
1.1/dabao/dist/vite.svg
vendored
Normal file
1
1.1/dabao/dist/vite.svg
vendored
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
1.1/dabao/requirements.txt
Normal file
BIN
1.1/dabao/requirements.txt
Normal file
Binary file not shown.
327
1.1/dabao/test1.py
Normal file
327
1.1/dabao/test1.py
Normal file
@ -0,0 +1,327 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
from lxml import etree
|
||||
|
||||
# --- 配置日志 ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
# --- 关键路径处理函数 (适配 PyInstaller) ---
|
||||
def get_base_path():
|
||||
"""获取运行时及其所在目录,适配开发环境和打包后的EXE环境"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 如果是打包后的 exe,sys.executable 是 exe 的路径
|
||||
return os.path.dirname(sys.executable)
|
||||
# 开发环境下,是当前脚本的路径
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_static_path():
|
||||
"""获取 Vue 静态资源 web_dist 的路径"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
||||
# 我们需要在打包命令中指定 --add-data "web_dist;web_dist"
|
||||
return os.path.join(sys._MEIPASS, 'web_dist')
|
||||
# 开发环境
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
|
||||
|
||||
|
||||
# --- Flask 初始化 ---
|
||||
# static_folder 指向 Vue 打包后的 web_dist 目录
|
||||
# static_url_path='' 表示静态文件不需要 /static 前缀
|
||||
dist_folder = get_static_path()
|
||||
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
|
||||
CORS(app)
|
||||
|
||||
# --- 数据库配置 ---
|
||||
# 确保数据库生成在 exe 同级目录下,而不是临时文件夹中
|
||||
db_path = os.path.join(get_base_path(), 'monitor_data.db')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SCHEDULER_API_ENABLED'] = True
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
scheduler = APScheduler()
|
||||
|
||||
|
||||
# --- 模型定义 (保持不变) ---
|
||||
class MonitorRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
source = db.Column(db.String(50))
|
||||
name = db.Column(db.String(100))
|
||||
status = db.Column(db.String(50))
|
||||
reason = db.Column(db.String(255))
|
||||
offset = db.Column(db.String(50))
|
||||
latest_time = db.Column(db.String(50))
|
||||
check_time = db.Column(db.String(50))
|
||||
content = db.Column(db.Text, nullable=True)
|
||||
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# --- 爬虫配置 (保持不变) ---
|
||||
CONFIG = {
|
||||
"106": {
|
||||
"base_url": "http://106.75.72.40:7500/api/proxy/tcp",
|
||||
"primary_auth": "Basic YWRtaW46bGljYWhr",
|
||||
"login_payload": {"username": "admin", "password": "licahk", "recaptcha": ""}
|
||||
},
|
||||
"82": {
|
||||
"base_url": "http://82.156.1.111/weather/php",
|
||||
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
|
||||
}
|
||||
}
|
||||
|
||||
is_running = False
|
||||
|
||||
|
||||
# --- 核心辅助函数 (保持不变) ---
|
||||
def calculate_offset(latest_time_str):
|
||||
if not latest_time_str or latest_time_str == "N/A":
|
||||
return "从未同步"
|
||||
try:
|
||||
clean_date_str = str(latest_time_str).split()[0].replace('_', '-')
|
||||
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
|
||||
diff = (datetime.now().date() - target_date).days
|
||||
if diff == 0: return "当天已同步"
|
||||
return f"滞后 {diff} 天"
|
||||
except:
|
||||
return "时间解析失败"
|
||||
|
||||
|
||||
def save_record(source, name, status, reason, latest_time="N/A", content=None):
|
||||
record = MonitorRecord.query.filter_by(source=source, name=name).first()
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_offset = calculate_offset(latest_time)
|
||||
|
||||
if record:
|
||||
if content is not None: record.content = content
|
||||
if latest_time != "N/A": record.latest_time = latest_time
|
||||
record.status = status
|
||||
record.reason = reason
|
||||
record.check_time = now_str
|
||||
time_base = latest_time if latest_time != "N/A" else record.latest_time
|
||||
record.offset = calculate_offset(time_base)
|
||||
else:
|
||||
new_record = MonitorRecord(
|
||||
source=source, name=name, status=status, reason=reason,
|
||||
offset=current_offset, latest_time=latest_time,
|
||||
check_time=now_str, content=content
|
||||
)
|
||||
db.session.add(new_record)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logging.error(f"DB Error: {e}")
|
||||
return f"{source}_{name}"
|
||||
|
||||
|
||||
# --- 业务逻辑函数 (保持不变) ---
|
||||
def get_106_dynamic_token(port):
|
||||
try:
|
||||
login_url = f"http://106.75.72.40:{port}/api/login"
|
||||
resp = requests.post(login_url, json=CONFIG["106"]["login_payload"], timeout=10)
|
||||
return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def find_closest_item(items, is_date_level=True):
|
||||
if not items or not isinstance(items, list): return None
|
||||
today = datetime.now()
|
||||
scored_items = []
|
||||
for item in items:
|
||||
name_val = item.get('name', '')
|
||||
path_val = item.get('path', '')
|
||||
target_str = name_val if name_val else path_val.split('/')[-1]
|
||||
try:
|
||||
if is_date_level:
|
||||
current_date = datetime.strptime(target_str, "%Y_%m_%d")
|
||||
else:
|
||||
mod_str = item.get('modified', '')
|
||||
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
||||
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
|
||||
scored_items.append((diff, item, target_str))
|
||||
except:
|
||||
continue
|
||||
if not scored_items: return None
|
||||
scored_items.sort(key=lambda x: x[0])
|
||||
return scored_items[0]
|
||||
|
||||
|
||||
def run_106_logic(active_set):
|
||||
# (保持原样,省略以节省空间,直接用你原本的逻辑即可)
|
||||
c = CONFIG["106"]
|
||||
today_str = datetime.now().strftime("%Y_%m_%d")
|
||||
main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
||||
try:
|
||||
resp = requests.get(c["base_url"], headers=main_headers, timeout=20)
|
||||
proxies = resp.json().get('proxies', [])
|
||||
for item in proxies:
|
||||
name = item.get('name', '')
|
||||
if not name.lower().endswith('_data'): continue
|
||||
if "TOWER" not in name.upper(): continue
|
||||
if str(item.get('status')).lower() != 'online':
|
||||
key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}")
|
||||
active_set.add(key)
|
||||
continue
|
||||
try:
|
||||
port = item.get('conf', {}).get('remote_port')
|
||||
token = get_106_dynamic_token(port)
|
||||
if not token:
|
||||
key = save_record("106网站", name, "异常", "Token获取失败")
|
||||
active_set.add(key)
|
||||
continue
|
||||
headers = {"Authorization": c["primary_auth"], "x-auth": token}
|
||||
api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/"
|
||||
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||
best_date = find_closest_item(res1.json().get('items', []), True)
|
||||
if not best_date or best_date[2] != today_str:
|
||||
key = save_record("106网站", name, "正常", "未找到今日文件夹",
|
||||
latest_time=best_date[2] if best_date else "N/A")
|
||||
active_set.add(key)
|
||||
continue
|
||||
date_path = f"{api_root}{best_date[2]}/"
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
|
||||
best_file = find_closest_item(res2.json().get('items', []), False)
|
||||
if not best_file:
|
||||
key = save_record("106网站", name, "正常", "今日文件夹为空", latest_time=today_str)
|
||||
active_set.add(key)
|
||||
continue
|
||||
file_item = best_file[1]
|
||||
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
|
||||
is_tower_i = "TOWER" in name.upper() and "TOWER_" not in name.upper()
|
||||
if is_tower_i:
|
||||
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||
res3 = requests.get(download_url, headers=headers, timeout=20)
|
||||
final_content = f"Binary Data Size: {len(res3.content)}"
|
||||
else:
|
||||
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
|
||||
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||
final_content = res3.json().get('content', '')
|
||||
key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content)
|
||||
active_set.add(key)
|
||||
except Exception as e:
|
||||
key = save_record("106网站", name, "异常", f"采集错误: {str(e)[:50]}")
|
||||
active_set.add(key)
|
||||
except Exception as e:
|
||||
logging.error(f"106 Global Error: {e}")
|
||||
|
||||
|
||||
def run_82_logic(active_set):
|
||||
# (保持原样,直接用你原本的逻辑即可)
|
||||
c = CONFIG["82"]
|
||||
session = requests.Session()
|
||||
try:
|
||||
session.post(f"{c['base_url']}/login.php", data=c["login"], timeout=10)
|
||||
resp = session.post(f"{c['base_url']}/GetStationList.php", timeout=10)
|
||||
stations = etree.HTML(resp.content).xpath('//option/@value')
|
||||
for sid in [s for s in stations if s]:
|
||||
try:
|
||||
r = session.post(f"{c['base_url']}/getLastWeatherData.php", data=str(sid),
|
||||
headers={'Content-Type': 'text/plain'}, timeout=10)
|
||||
data = r.json()
|
||||
if data:
|
||||
d_list = data.get('date', [])
|
||||
latest = str(d_list[-1]) if d_list else "N/A"
|
||||
key = save_record("82网站", sid, "正常", "同步成功", latest_time=latest,
|
||||
content=json.dumps(data, ensure_ascii=False))
|
||||
active_set.add(key)
|
||||
else:
|
||||
key = save_record("82网站", sid, "异常", "返回空数据")
|
||||
active_set.add(key)
|
||||
except:
|
||||
key = save_record("82网站", sid, "异常", "单个采集失败")
|
||||
active_set.add(key)
|
||||
except Exception as e:
|
||||
logging.error(f"82 Global Error: {e}")
|
||||
|
||||
|
||||
def execute_monitor_task():
|
||||
global is_running
|
||||
if is_running: return
|
||||
is_running = True
|
||||
logging.info("Starting monitor task...")
|
||||
with app.app_context():
|
||||
active_set = set()
|
||||
run_106_logic(active_set)
|
||||
run_82_logic(active_set)
|
||||
all_records = MonitorRecord.query.all()
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
for record in all_records:
|
||||
if f"{record.source}_{record.name}" not in active_set:
|
||||
record.status = "已离线"
|
||||
record.reason = "设备本次未出现"
|
||||
record.check_time = now_str
|
||||
record.offset = calculate_offset(record.latest_time)
|
||||
try:
|
||||
db.session.commit()
|
||||
except:
|
||||
db.session.rollback()
|
||||
is_running = False
|
||||
logging.info("Monitor task finished.")
|
||||
|
||||
|
||||
# --- API 路由 (保持不变) ---
|
||||
@app.route('/api/run', methods=['POST'])
|
||||
def manual_start():
|
||||
if is_running: return jsonify({"status": "busy"}), 400
|
||||
threading.Thread(target=execute_monitor_task).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@app.route('/api/status')
|
||||
def status(): return jsonify({"is_running": is_running})
|
||||
|
||||
|
||||
@app.route('/api/logs')
|
||||
def logs():
|
||||
data = MonitorRecord.query.all()
|
||||
return jsonify([{
|
||||
"source": l.source, "name": l.name, "status": l.status,
|
||||
"reason": l.reason, "offset": l.offset, "latest_time": l.latest_time,
|
||||
"check_time": l.check_time, "content": l.content
|
||||
} for l in data])
|
||||
|
||||
|
||||
# --- 新增: 前端页面托管路由 ---
|
||||
@app.route('/')
|
||||
def serve_index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def serve_static_files(path):
|
||||
# 尝试在 web_dist 目录寻找文件 (css, js, icons)
|
||||
file_path = os.path.join(app.static_folder, path)
|
||||
if os.path.exists(file_path):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
# 如果找不到文件(例如刷新页面时的路由),返回index.html让Vue Router处理
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
|
||||
# --- 调度器与启动 ---
|
||||
@scheduler.task('cron', id='daily_job', hour=10, minute=0)
|
||||
def auto_run_task():
|
||||
with app.app_context():
|
||||
threading.Thread(target=execute_monitor_task).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scheduler.init_app(app)
|
||||
scheduler.start()
|
||||
# Host='0.0.0.0' 允许外部IP访问
|
||||
# Port=5000 (确保 Windows 防火墙开放了此端口)
|
||||
print("应用正在启动... 请确保 web_dist 文件夹与脚本/exe 同级或已被打包")
|
||||
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
||||
BIN
1.1/data/frps_106/TowerIS2_012_data_2026_01_06.bin
Normal file
BIN
1.1/data/frps_106/TowerIS2_012_data_2026_01_06.bin
Normal file
Binary file not shown.
135
1.1/frps.py
Normal file
135
1.1/frps.py
Normal file
@ -0,0 +1,135 @@
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# --- 配置保持不变 ---
|
||||
BASE_URL = "http://106.75.72.40:7500/api/proxy/tcp"
|
||||
PRIMARY_AUTH = "Basic YWRtaW46bGljYWhr"
|
||||
X_AUTH = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoxLCJsb2NhbGUiOiJ6aC1jbiIsInZpZXdNb2RlIjoibGlzdCIsInNpbmdsZUNsaWNrIjpmYWxzZSwicGVybSI6eyJhZG1pbiI6dHJ1ZSwiZXhlY3V0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJyZW5hbWUiOnRydWUsIm1vZGlmeSI6dHJ1ZSwiZGVsZXRlIjp0cnVlLCJzaGFyZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9LCJjb21tYW5kcyI6W10sImxvY2tQYXNzd29yZCI6ZmFsc2UsImhpZGVEb3RmaWxlcyI6ZmFsc2V9LCJleHAiOjE3Njc2Njg3NzgsImlhdCI6MTc2NzY2MTU3OCwiaXNzIjoiRmlsZSBCcm93c2VyIn0.z9zycFSf3XpUDRhGjziUJ-PUeHIsRba23AI6itqXM-w"
|
||||
|
||||
headers = {
|
||||
"Authorization": PRIMARY_AUTH,
|
||||
"x-auth": X_AUTH,
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
}
|
||||
|
||||
|
||||
def get_today_str():
|
||||
return datetime.now().strftime("%Y_%m_%d")
|
||||
|
||||
|
||||
def find_closest_item(items, is_date_level=True):
|
||||
if not items or not isinstance(items, list): return None
|
||||
today = datetime.now()
|
||||
scored_items = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict): continue
|
||||
path = item.get('path', '')
|
||||
if not path: continue
|
||||
try:
|
||||
if is_date_level:
|
||||
date_str = path.split('/')[-1]
|
||||
current_date = datetime.strptime(date_str, "%Y_%m_%d")
|
||||
else:
|
||||
mod_str = item.get('modified', '')
|
||||
if mod_str:
|
||||
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
continue
|
||||
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
|
||||
scored_items.append((diff, item))
|
||||
except:
|
||||
continue
|
||||
|
||||
if not scored_items: return None
|
||||
scored_items.sort(key=lambda x: x[0])
|
||||
return scored_items[0][1]
|
||||
|
||||
|
||||
def debug_process_106():
|
||||
today_str = get_today_str()
|
||||
print(f"=== 开始 106 网站深度调试 (目标日期: {today_str}) ===")
|
||||
|
||||
try:
|
||||
resp = requests.get(BASE_URL, headers=headers, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
print(f"❌ 错误: 主接口访问失败, 状态码: {resp.status_code}")
|
||||
return
|
||||
|
||||
proxies = resp.json().get('proxies', [])
|
||||
data_proxies = [p for p in proxies if p.get('name', '').endswith('_data')]
|
||||
|
||||
for item in data_proxies:
|
||||
name = item.get('name', 'Unknown')
|
||||
# 每一个站点的处理都包裹在独立的 try 里,防止相互干扰
|
||||
try:
|
||||
status = str(item.get('status', '')).lower().strip()
|
||||
conf = item.get('conf') or {}
|
||||
port = conf.get('remote_port')
|
||||
|
||||
print(f"--- [检查站点: {name}] ---")
|
||||
|
||||
if status != 'online':
|
||||
print(f" ⚠ 判定错误: 站点离线 ({status})")
|
||||
continue
|
||||
|
||||
if not port:
|
||||
print(f" ❌ 判定错误: 缺少端口配置")
|
||||
continue
|
||||
|
||||
# Data 目录请求
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}/api/resources/Data/", headers=headers, timeout=10)
|
||||
if res2.status_code != 200:
|
||||
print(f" ❌ 判定错误: Data目录 HTTP {res2.status_code}")
|
||||
continue
|
||||
|
||||
it2 = res2.json().get('items', [])
|
||||
closest_date = find_closest_item(it2, True)
|
||||
if not closest_date:
|
||||
print(f" ❌ 判定错误: Data目录为空")
|
||||
continue
|
||||
|
||||
path_date = closest_date.get('path', '')
|
||||
date_val = path_date.split('/')[-1]
|
||||
|
||||
if date_val != today_str:
|
||||
print(f" ⚠ 判定错误: 日期不符 (最新: {date_val})")
|
||||
continue
|
||||
|
||||
# 文件列表请求
|
||||
res3 = requests.get(f"http://106.75.72.40:{port}/api/resources{path_date}/", headers=headers,
|
||||
timeout=10)
|
||||
it3 = res3.json().get('items', [])
|
||||
closest_file = find_closest_item(it3, False)
|
||||
if not closest_file:
|
||||
print(f" ❌ 判定错误: 日期文件夹内无文件")
|
||||
continue
|
||||
|
||||
# 文件内容请求 - 关键防御点
|
||||
path_csv = closest_file.get('path', '')
|
||||
res4 = requests.get(f"http://106.75.72.40:{port}/api/resources{path_csv}", headers=headers, timeout=10)
|
||||
|
||||
try:
|
||||
file_data = res4.json()
|
||||
if file_data is None:
|
||||
print(f" ❌ 判定错误: 接口返回了 Null (NoneType)")
|
||||
continue
|
||||
|
||||
content = file_data.get('content', '')
|
||||
if not content:
|
||||
print(f" ❌ 判定错误: content 字段为空")
|
||||
else:
|
||||
print(f" ✅ 检查通过: 数据最新,长度 {len(content)}")
|
||||
except Exception as json_e:
|
||||
print(f" ❌ 判定错误: 解析 JSON 失败 (非标准格式)")
|
||||
|
||||
except Exception as site_e:
|
||||
print(f" ❌ 判定错误: 站点逻辑崩溃: {site_e}")
|
||||
|
||||
except Exception as global_e:
|
||||
print(f"❌ 全局严重错误: {global_e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_process_106()
|
||||
169
1.1/frps_final.py
Normal file
169
1.1/frps_final.py
Normal file
@ -0,0 +1,169 @@
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
# --- 基础配置 ---
|
||||
BASE_URL = "http://106.75.72.40:7500/api/proxy/tcp"
|
||||
PRIMARY_AUTH = "Basic YWRtaW46bGljYWhr"
|
||||
LOGIN_PAYLOAD = {"username": "admin", "password": "licahk", "recaptcha": ""}
|
||||
|
||||
SAVE_DIR = "downloaded_data"
|
||||
if not os.path.exists(SAVE_DIR):
|
||||
os.makedirs(SAVE_DIR)
|
||||
|
||||
|
||||
def get_today_str():
|
||||
return datetime.now().strftime("%Y_%m_%d")
|
||||
|
||||
|
||||
def get_dynamic_token(port):
|
||||
"""
|
||||
为指定端口的站点执行登录,获取最新的 x-auth token
|
||||
"""
|
||||
login_url = f"http://106.75.72.40:{port}/api/login"
|
||||
try:
|
||||
# 登录不需要 x-auth,只需要 Basic Auth 或特定的 Payload
|
||||
resp = requests.post(login_url, json=LOGIN_PAYLOAD, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
# 登录成功后,token 通常直接返回在响应体中(纯字符串或 JSON)
|
||||
token = resp.text.strip().replace('"', '')
|
||||
return token
|
||||
else:
|
||||
print(f" ❌ 登录失败: 端口 {port}, 状态码 {resp.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" ❌ 登录异常: {port}, {e}")
|
||||
return None
|
||||
|
||||
|
||||
def find_closest_item(items, is_date_level=True):
|
||||
if not items or not isinstance(items, list): return None
|
||||
today = datetime.now()
|
||||
scored_items = []
|
||||
today_str = get_today_str()
|
||||
|
||||
for item in items:
|
||||
name_val = item.get('name', '')
|
||||
path_val = item.get('path', '')
|
||||
target_str = name_val if name_val else path_val.split('/')[-1]
|
||||
|
||||
try:
|
||||
if is_date_level:
|
||||
current_date = datetime.strptime(target_str, "%Y_%m_%d")
|
||||
else:
|
||||
mod_str = item.get('modified', '')
|
||||
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
||||
|
||||
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
|
||||
scored_items.append((diff, item, target_str))
|
||||
except:
|
||||
continue
|
||||
|
||||
if not scored_items: return None
|
||||
scored_items.sort(key=lambda x: x[0])
|
||||
return scored_items[0]
|
||||
|
||||
|
||||
def process_site(proxy_item):
|
||||
name = proxy_item.get('name', '')
|
||||
# 严格过滤:只处理以 _data 结尾的站点
|
||||
if not name.lower().endswith('_data'):
|
||||
return
|
||||
|
||||
name_upper = name.upper()
|
||||
is_tower_underscore = "TOWER_" in name_upper
|
||||
is_tower_i = "TOWER" in name_upper and not is_tower_underscore
|
||||
|
||||
if not (is_tower_underscore or is_tower_i):
|
||||
return
|
||||
|
||||
print(f"\n--- [正在处理站点: {name}] ---")
|
||||
try:
|
||||
port = proxy_item.get('conf', {}).get('remote_port')
|
||||
if not port: return
|
||||
|
||||
# 动态获取当前站点的 Token
|
||||
token = get_dynamic_token(port)
|
||||
if not token:
|
||||
print(f" 跳过: 无法获取有效的 x-auth")
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Authorization": PRIMARY_AUTH,
|
||||
"x-auth": token,
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
}
|
||||
today_str = get_today_str()
|
||||
|
||||
# Step 1: 进入 data 根目录 (路径根据类型区分大小写)
|
||||
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
|
||||
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||
|
||||
# Step 2: 寻找并进入日期目录
|
||||
items1 = res1.json().get('items', [])
|
||||
best_date = find_closest_item(items1, is_date_level=True)
|
||||
if not best_date or best_date[2] != today_str:
|
||||
print(f" ⚠ 跳过: 未找到今日 ({today_str}) 的文件夹")
|
||||
return
|
||||
|
||||
date_path = f"{api_root}{best_date[2]}/"
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
|
||||
|
||||
# Step 3: 寻找日期目录下的最新文件
|
||||
items2 = res2.json().get('items', [])
|
||||
best_file = find_closest_item(items2, is_date_level=False)
|
||||
if not best_file:
|
||||
print(f" ❌ 日期文件夹内无文件")
|
||||
return
|
||||
|
||||
# 获取文件的完整 path
|
||||
file_item = best_file[1]
|
||||
full_path = file_item.get('path')
|
||||
if not full_path:
|
||||
full_path = f"{date_path}{file_item.get('name')}"
|
||||
|
||||
# --- 获取内容层级 (根据站点类型采用不同接口) ---
|
||||
if is_tower_i:
|
||||
# TowerI 模式:使用 /api/raw/{path} 获取二进制流
|
||||
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||
res3 = requests.get(download_url, headers=headers, timeout=20, stream=True)
|
||||
if res3.status_code == 200:
|
||||
save_path = os.path.join(SAVE_DIR, f"{name}_{today_str}.bin")
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(res3.content)
|
||||
print(f" ✅ 二进制保存成功: {save_path} (大小: {len(res3.content)} 字节)")
|
||||
else:
|
||||
# Tower_ 模式:原来的 JSON content 模式
|
||||
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
|
||||
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||
try:
|
||||
content_str = res3.json().get('content', '')
|
||||
if content_str:
|
||||
save_path = os.path.join(SAVE_DIR, f"{name}_{today_str}.json")
|
||||
with open(save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content_str)
|
||||
print(f" ✅ JSON数据保存成功: {save_path}")
|
||||
except:
|
||||
print(f" ❌ TOWER_ 站点格式错误或无内容")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 站点处理失败: {str(e)}")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"任务启动: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
# 主接口获取站点列表
|
||||
try:
|
||||
# 注意:主控制台可能也需要 token,这里先用硬编码,如果不行也需要为 7500 端口做登录
|
||||
main_headers = {"Authorization": PRIMARY_AUTH, "User-Agent": "Mozilla/5.0"}
|
||||
resp = requests.get(BASE_URL, headers=main_headers, timeout=15)
|
||||
proxies = resp.json().get('proxies', [])
|
||||
for p in proxies:
|
||||
process_site(p)
|
||||
except Exception as e:
|
||||
print(f"❌ 全局错误: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
327
1.1/test1.py
Normal file
327
1.1/test1.py
Normal file
@ -0,0 +1,327 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
from lxml import etree
|
||||
|
||||
# --- 配置日志 ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
# --- 关键路径处理函数 (适配 PyInstaller) ---
|
||||
def get_base_path():
|
||||
"""获取运行时及其所在目录,适配开发环境和打包后的EXE环境"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 如果是打包后的 exe,sys.executable 是 exe 的路径
|
||||
return os.path.dirname(sys.executable)
|
||||
# 开发环境下,是当前脚本的路径
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_static_path():
|
||||
"""获取 Vue 静态资源 web_dist 的路径"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
||||
# 我们需要在打包命令中指定 --add-data "web_dist;web_dist"
|
||||
return os.path.join(sys._MEIPASS, 'web_dist')
|
||||
# 开发环境
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
|
||||
|
||||
|
||||
# --- Flask 初始化 ---
|
||||
# static_folder 指向 Vue 打包后的 web_dist 目录
|
||||
# static_url_path='' 表示静态文件不需要 /static 前缀
|
||||
dist_folder = get_static_path()
|
||||
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
|
||||
CORS(app)
|
||||
|
||||
# --- 数据库配置 ---
|
||||
# 确保数据库生成在 exe 同级目录下,而不是临时文件夹中
|
||||
db_path = os.path.join(get_base_path(), 'monitor_data.db')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SCHEDULER_API_ENABLED'] = True
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
scheduler = APScheduler()
|
||||
|
||||
|
||||
# --- 模型定义 (保持不变) ---
|
||||
class MonitorRecord(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
source = db.Column(db.String(50))
|
||||
name = db.Column(db.String(100))
|
||||
status = db.Column(db.String(50))
|
||||
reason = db.Column(db.String(255))
|
||||
offset = db.Column(db.String(50))
|
||||
latest_time = db.Column(db.String(50))
|
||||
check_time = db.Column(db.String(50))
|
||||
content = db.Column(db.Text, nullable=True)
|
||||
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# --- 爬虫配置 (保持不变) ---
|
||||
CONFIG = {
|
||||
"106": {
|
||||
"base_url": "http://106.75.72.40:7500/api/proxy/tcp",
|
||||
"primary_auth": "Basic YWRtaW46bGljYWhr",
|
||||
"login_payload": {"username": "admin", "password": "licahk", "recaptcha": ""}
|
||||
},
|
||||
"82": {
|
||||
"base_url": "http://82.156.1.111/weather/php",
|
||||
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
|
||||
}
|
||||
}
|
||||
|
||||
is_running = False
|
||||
|
||||
|
||||
# --- 核心辅助函数 (保持不变) ---
|
||||
def calculate_offset(latest_time_str):
|
||||
if not latest_time_str or latest_time_str == "N/A":
|
||||
return "从未同步"
|
||||
try:
|
||||
clean_date_str = str(latest_time_str).split()[0].replace('_', '-')
|
||||
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
|
||||
diff = (datetime.now().date() - target_date).days
|
||||
if diff == 0: return "当天已同步"
|
||||
return f"滞后 {diff} 天"
|
||||
except:
|
||||
return "时间解析失败"
|
||||
|
||||
|
||||
def save_record(source, name, status, reason, latest_time="N/A", content=None):
|
||||
record = MonitorRecord.query.filter_by(source=source, name=name).first()
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
current_offset = calculate_offset(latest_time)
|
||||
|
||||
if record:
|
||||
if content is not None: record.content = content
|
||||
if latest_time != "N/A": record.latest_time = latest_time
|
||||
record.status = status
|
||||
record.reason = reason
|
||||
record.check_time = now_str
|
||||
time_base = latest_time if latest_time != "N/A" else record.latest_time
|
||||
record.offset = calculate_offset(time_base)
|
||||
else:
|
||||
new_record = MonitorRecord(
|
||||
source=source, name=name, status=status, reason=reason,
|
||||
offset=current_offset, latest_time=latest_time,
|
||||
check_time=now_str, content=content
|
||||
)
|
||||
db.session.add(new_record)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logging.error(f"DB Error: {e}")
|
||||
return f"{source}_{name}"
|
||||
|
||||
|
||||
# --- 业务逻辑函数 (保持不变) ---
|
||||
def get_106_dynamic_token(port):
|
||||
try:
|
||||
login_url = f"http://106.75.72.40:{port}/api/login"
|
||||
resp = requests.post(login_url, json=CONFIG["106"]["login_payload"], timeout=10)
|
||||
return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def find_closest_item(items, is_date_level=True):
|
||||
if not items or not isinstance(items, list): return None
|
||||
today = datetime.now()
|
||||
scored_items = []
|
||||
for item in items:
|
||||
name_val = item.get('name', '')
|
||||
path_val = item.get('path', '')
|
||||
target_str = name_val if name_val else path_val.split('/')[-1]
|
||||
try:
|
||||
if is_date_level:
|
||||
current_date = datetime.strptime(target_str, "%Y_%m_%d")
|
||||
else:
|
||||
mod_str = item.get('modified', '')
|
||||
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
||||
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
|
||||
scored_items.append((diff, item, target_str))
|
||||
except:
|
||||
continue
|
||||
if not scored_items: return None
|
||||
scored_items.sort(key=lambda x: x[0])
|
||||
return scored_items[0]
|
||||
|
||||
|
||||
def run_106_logic(active_set):
|
||||
# (保持原样,省略以节省空间,直接用你原本的逻辑即可)
|
||||
c = CONFIG["106"]
|
||||
today_str = datetime.now().strftime("%Y_%m_%d")
|
||||
main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
||||
try:
|
||||
resp = requests.get(c["base_url"], headers=main_headers, timeout=20)
|
||||
proxies = resp.json().get('proxies', [])
|
||||
for item in proxies:
|
||||
name = item.get('name', '')
|
||||
if not name.lower().endswith('_data'): continue
|
||||
if "TOWER" not in name.upper(): continue
|
||||
if str(item.get('status')).lower() != 'online':
|
||||
key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}")
|
||||
active_set.add(key)
|
||||
continue
|
||||
try:
|
||||
port = item.get('conf', {}).get('remote_port')
|
||||
token = get_106_dynamic_token(port)
|
||||
if not token:
|
||||
key = save_record("106网站", name, "异常", "Token获取失败")
|
||||
active_set.add(key)
|
||||
continue
|
||||
headers = {"Authorization": c["primary_auth"], "x-auth": token}
|
||||
api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/"
|
||||
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||
best_date = find_closest_item(res1.json().get('items', []), True)
|
||||
if not best_date or best_date[2] != today_str:
|
||||
key = save_record("106网站", name, "正常", "未找到今日文件夹",
|
||||
latest_time=best_date[2] if best_date else "N/A")
|
||||
active_set.add(key)
|
||||
continue
|
||||
date_path = f"{api_root}{best_date[2]}/"
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
|
||||
best_file = find_closest_item(res2.json().get('items', []), False)
|
||||
if not best_file:
|
||||
key = save_record("106网站", name, "正常", "今日文件夹为空", latest_time=today_str)
|
||||
active_set.add(key)
|
||||
continue
|
||||
file_item = best_file[1]
|
||||
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
|
||||
is_tower_i = "TOWER" in name.upper() and "TOWER_" not in name.upper()
|
||||
if is_tower_i:
|
||||
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||
res3 = requests.get(download_url, headers=headers, timeout=20)
|
||||
final_content = f"Binary Data Size: {len(res3.content)}"
|
||||
else:
|
||||
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
|
||||
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||
final_content = res3.json().get('content', '')
|
||||
key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content)
|
||||
active_set.add(key)
|
||||
except Exception as e:
|
||||
key = save_record("106网站", name, "异常", f"采集错误: {str(e)[:50]}")
|
||||
active_set.add(key)
|
||||
except Exception as e:
|
||||
logging.error(f"106 Global Error: {e}")
|
||||
|
||||
|
||||
def run_82_logic(active_set):
|
||||
# (保持原样,直接用你原本的逻辑即可)
|
||||
c = CONFIG["82"]
|
||||
session = requests.Session()
|
||||
try:
|
||||
session.post(f"{c['base_url']}/login.php", data=c["login"], timeout=10)
|
||||
resp = session.post(f"{c['base_url']}/GetStationList.php", timeout=10)
|
||||
stations = etree.HTML(resp.content).xpath('//option/@value')
|
||||
for sid in [s for s in stations if s]:
|
||||
try:
|
||||
r = session.post(f"{c['base_url']}/getLastWeatherData.php", data=str(sid),
|
||||
headers={'Content-Type': 'text/plain'}, timeout=10)
|
||||
data = r.json()
|
||||
if data:
|
||||
d_list = data.get('date', [])
|
||||
latest = str(d_list[-1]) if d_list else "N/A"
|
||||
key = save_record("82网站", sid, "正常", "同步成功", latest_time=latest,
|
||||
content=json.dumps(data, ensure_ascii=False))
|
||||
active_set.add(key)
|
||||
else:
|
||||
key = save_record("82网站", sid, "异常", "返回空数据")
|
||||
active_set.add(key)
|
||||
except:
|
||||
key = save_record("82网站", sid, "异常", "单个采集失败")
|
||||
active_set.add(key)
|
||||
except Exception as e:
|
||||
logging.error(f"82 Global Error: {e}")
|
||||
|
||||
|
||||
def execute_monitor_task():
|
||||
global is_running
|
||||
if is_running: return
|
||||
is_running = True
|
||||
logging.info("Starting monitor task...")
|
||||
with app.app_context():
|
||||
active_set = set()
|
||||
run_106_logic(active_set)
|
||||
run_82_logic(active_set)
|
||||
all_records = MonitorRecord.query.all()
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
for record in all_records:
|
||||
if f"{record.source}_{record.name}" not in active_set:
|
||||
record.status = "已离线"
|
||||
record.reason = "设备本次未出现"
|
||||
record.check_time = now_str
|
||||
record.offset = calculate_offset(record.latest_time)
|
||||
try:
|
||||
db.session.commit()
|
||||
except:
|
||||
db.session.rollback()
|
||||
is_running = False
|
||||
logging.info("Monitor task finished.")
|
||||
|
||||
|
||||
# --- API 路由 (保持不变) ---
|
||||
@app.route('/api/run', methods=['POST'])
|
||||
def manual_start():
|
||||
if is_running: return jsonify({"status": "busy"}), 400
|
||||
threading.Thread(target=execute_monitor_task).start()
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
|
||||
@app.route('/api/status')
|
||||
def status(): return jsonify({"is_running": is_running})
|
||||
|
||||
|
||||
@app.route('/api/logs')
|
||||
def logs():
|
||||
data = MonitorRecord.query.all()
|
||||
return jsonify([{
|
||||
"source": l.source, "name": l.name, "status": l.status,
|
||||
"reason": l.reason, "offset": l.offset, "latest_time": l.latest_time,
|
||||
"check_time": l.check_time, "content": l.content
|
||||
} for l in data])
|
||||
|
||||
|
||||
# --- 新增: 前端页面托管路由 ---
|
||||
@app.route('/')
|
||||
def serve_index():
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def serve_static_files(path):
|
||||
# 尝试在 web_dist 目录寻找文件 (css, js, icons)
|
||||
file_path = os.path.join(app.static_folder, path)
|
||||
if os.path.exists(file_path):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
# 如果找不到文件(例如刷新页面时的路由),返回index.html让Vue Router处理
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
|
||||
# --- 调度器与启动 ---
|
||||
@scheduler.task('cron', id='daily_job', hour=10, minute=0)
|
||||
def auto_run_task():
|
||||
with app.app_context():
|
||||
threading.Thread(target=execute_monitor_task).start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
scheduler.init_app(app)
|
||||
scheduler.start()
|
||||
# Host='0.0.0.0' 允许外部IP访问
|
||||
# Port=5000 (确保 Windows 防火墙开放了此端口)
|
||||
print("应用正在启动... 请确保 web_dist 文件夹与脚本/exe 同级或已被打包")
|
||||
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
||||
121
1.1/光谱气象站final.py
Normal file
121
1.1/光谱气象站final.py
Normal file
@ -0,0 +1,121 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import requests
|
||||
from lxml import etree
|
||||
from datetime import datetime
|
||||
|
||||
# --- 配置区 ---
|
||||
BASE_URL = "http://82.156.1.111/weather/php"
|
||||
LOGIN_DATA = {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
|
||||
OUTPUT_DIR = "weather_data_test"
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
|
||||
def fetch_stations():
|
||||
resp = session.post(f"{BASE_URL}/GetStationList.php")
|
||||
if resp.status_code != 200: return []
|
||||
tree = etree.HTML(resp.content)
|
||||
stations = [s for s in tree.xpath('//option/@value') if s and str(s).strip()]
|
||||
return stations
|
||||
|
||||
|
||||
def get_latest_data_item(station_id, data_list):
|
||||
"""
|
||||
核心改进:从列表中筛选出日期距离今天最近的一条数据
|
||||
"""
|
||||
if not data_list or not isinstance(data_list, list):
|
||||
return None
|
||||
|
||||
today = datetime.now().date()
|
||||
parsed_items = []
|
||||
|
||||
for item in data_list:
|
||||
try:
|
||||
# 兼容处理:如果是字符串列表,直接解析;如果是对象列表,取特定键
|
||||
date_str = item.split()[0] if isinstance(item, str) else item.get('date', '').split()[0]
|
||||
current_date = datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
|
||||
# 计算与今天的差异(天数绝对值)
|
||||
diff = abs((today - current_date).days)
|
||||
parsed_items.append({
|
||||
'diff': diff,
|
||||
'date_obj': current_date,
|
||||
'original_data': item
|
||||
})
|
||||
except:
|
||||
continue
|
||||
|
||||
if not parsed_items:
|
||||
return None
|
||||
|
||||
# --- 逻辑更新:按时间差异升序排序(diff越小说明离今天越近) ---
|
||||
parsed_items.sort(key=lambda x: x['diff'])
|
||||
|
||||
# 返回距离最近的那一条原始数据
|
||||
return parsed_items[0]
|
||||
|
||||
|
||||
def save_json(name, data):
|
||||
path = os.path.join(OUTPUT_DIR, f"{name}.json")
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def main():
|
||||
print("正在登录...")
|
||||
session.post(f"{BASE_URL}/login.php", data=LOGIN_DATA)
|
||||
|
||||
stations = fetch_stations()
|
||||
print(f"成功获取 {len(stations)} 个有效站点")
|
||||
|
||||
for i, sid in enumerate(stations, 1):
|
||||
print(f"[{i}/{len(stations)}] 处理站点: {sid}")
|
||||
try:
|
||||
resp = session.post(f"{BASE_URL}/getLastWeatherData.php",
|
||||
data=str(sid),
|
||||
headers={'Content-Type': 'text/plain'})
|
||||
|
||||
if resp.status_code == 200:
|
||||
full_data = resp.json()
|
||||
|
||||
# 假设 API 返回的 JSON 中 'date' 键对应的是数据列表
|
||||
# 我们调用 get_latest_data_item 来锁定那唯一的一条最新数据
|
||||
data_key = 'date' if 'date' in full_data else 'items' # 根据实际键名调整
|
||||
target_list = full_data.get(data_key, [])
|
||||
|
||||
latest_result = get_latest_data_item(sid, target_list)
|
||||
|
||||
if latest_result:
|
||||
# 重新构造保存内容:只保留最接近今天的数据
|
||||
final_payload = {
|
||||
"proxy_name": sid,
|
||||
"status": "online",
|
||||
"latest_date": str(latest_result['date_obj']),
|
||||
"days_diff": latest_result['diff'],
|
||||
"data_content": latest_result['original_data']
|
||||
}
|
||||
|
||||
# 如果不是今天,输出警告
|
||||
if latest_result['diff'] != 0:
|
||||
print(f" ⚠️ 非当天数据: {latest_result['date_obj']} (差 {latest_result['diff']} 天)")
|
||||
else:
|
||||
print(f" ✨ 数据已同步至今天")
|
||||
|
||||
save_json(sid, final_payload)
|
||||
else:
|
||||
print(f" ⚪ 站点 {sid} 未找到有效日期数据")
|
||||
|
||||
else:
|
||||
print(f" ❌ 请求失败: {resp.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 错误: {e}")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
1.1/光谱气象站展示.py
Normal file
0
1.1/光谱气象站展示.py
Normal file
151
1.1/整合.py
151
1.1/整合.py
@ -19,7 +19,7 @@ CONFIG = {
|
||||
"106": {
|
||||
"base_url": "http://106.75.72.40:7500/api/proxy/tcp",
|
||||
"primary_auth": "Basic YWRtaW46bGljYWhr",
|
||||
"x_auth": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoxLCJsb2NhbGUiOiJ6aC1jbiIsInZpZXdNb2RlIjoibGlzdCIsInNpbmdsZUNsaWNrIjpmYWxzZSwicGVybSI6eyJhZG1pbiI6dHJ1ZSwiZXhlY3V0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJyZW5hbWUiOnRydWUsIm1vZGlmeSI6dHJ1ZSwiZGVsZXRlIjp0cnVlLCJzaGFyZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9LCJjb21tYW5kcyI6W10sImxvY2tQYXNzd29yZCI6ZmFsc2UsImhpZGVEb3RmaWxlcyI6ZmFsc2V9LCJleHAiOjE3Njc2Njg3NzgsImlhdCI6MTc2NzY2MTU3OCwiaXNzIjoiRmlsZSBCcm93c2VyIn0.z9zycFSf3XpUDRhGjziUJ-PUeHIsRba23AI6itqXM-w"
|
||||
"login_payload": {"username": "admin", "password": "licahk", "recaptcha": ""}
|
||||
},
|
||||
"82": {
|
||||
"base_url": "http://82.156.1.111/weather/php",
|
||||
@ -37,7 +37,6 @@ def add_error(source, name, reason, latest_time="N/A"):
|
||||
days_diff = "N/A"
|
||||
if latest_time and latest_time != "N/A":
|
||||
try:
|
||||
# 兼容 2026_01_06 和 2026-01-06 格式
|
||||
clean_date_str = str(latest_time).split()[0].replace('_', '-')
|
||||
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
|
||||
today_date = datetime.now().date()
|
||||
@ -63,12 +62,12 @@ def find_closest_item(items, is_date_level=True):
|
||||
scored_items = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict): continue
|
||||
path = item.get('path', '')
|
||||
if not path: continue
|
||||
name_val = item.get('name', '')
|
||||
path_val = item.get('path', '')
|
||||
target_str = name_val if name_val else path_val.split('/')[-1]
|
||||
try:
|
||||
if is_date_level:
|
||||
date_str = path.split('/')[-1]
|
||||
current_date = datetime.strptime(date_str, "%Y_%m_%d")
|
||||
current_date = datetime.strptime(target_str, "%Y_%m_%d")
|
||||
else:
|
||||
mod_str = item.get('modified', '')
|
||||
if mod_str:
|
||||
@ -76,13 +75,12 @@ def find_closest_item(items, is_date_level=True):
|
||||
else:
|
||||
continue
|
||||
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
|
||||
scored_items.append((diff, item))
|
||||
scored_items.append((diff, item, target_str))
|
||||
except:
|
||||
continue
|
||||
|
||||
if not scored_items: return None
|
||||
scored_items.sort(key=lambda x: x[0])
|
||||
return scored_items[0][1]
|
||||
return scored_items[0]
|
||||
|
||||
|
||||
def process_text_content(raw_content):
|
||||
@ -105,16 +103,28 @@ def save_json(folder, name, data):
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
|
||||
|
||||
# --- 106 网站逻辑 (强化防御版) ---
|
||||
# --- 106 网站逻辑 ---
|
||||
|
||||
def get_106_dynamic_token(port):
|
||||
"""为106特定端口站点执行登录获取 Token"""
|
||||
url = f"http://106.75.72.40:{port}/api/login"
|
||||
try:
|
||||
resp = requests.post(url, json=CONFIG["106"]["login_payload"], timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return resp.text.strip().replace('"', '')
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def run_106_logic():
|
||||
print("\n>>> 开始处理 106 网站 (FRPS)...")
|
||||
print("\n>>> 开始处理 106 网站 (FRPS - 修正逻辑)...")
|
||||
c = CONFIG["106"]
|
||||
headers = {"Authorization": c["primary_auth"], "x-auth": c["x_auth"], "User-Agent": "Mozilla/5.0"}
|
||||
today_str = datetime.now().strftime("%Y_%m_%d")
|
||||
|
||||
try:
|
||||
resp = requests.get(c["base_url"], headers=headers, timeout=15)
|
||||
main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
||||
resp = requests.get(c["base_url"], headers=main_headers, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
add_error("106网站", "主入口API", f"访问失败: HTTP {resp.status_code}")
|
||||
return
|
||||
@ -123,81 +133,97 @@ def run_106_logic():
|
||||
for item in proxies:
|
||||
if not isinstance(item, dict): continue
|
||||
name = item.get('name', 'Unknown')
|
||||
if not name.endswith('_data'): continue
|
||||
if not name.lower().endswith('_data'): continue
|
||||
|
||||
# 1. 状态预检 - 离线拦截逻辑
|
||||
status_raw = item.get('status', '')
|
||||
status = str(status_raw).lower().strip() if status_raw else "unknown"
|
||||
|
||||
# 如果离线,直接保存并记录错误,不再进行后续 API 访问
|
||||
if status != 'online':
|
||||
add_error("106网站", name, f"设备离线 (当前状态: {status})")
|
||||
save_json(FRPS_DIR, name, item)
|
||||
continue
|
||||
|
||||
# 2. 识别站点类型并进行在线处理
|
||||
name_up = name.upper()
|
||||
is_tower_underscore = "TOWER_" in name_up
|
||||
is_tower_i = "TOWER" in name_up and not is_tower_underscore
|
||||
if not (is_tower_underscore or is_tower_i): continue
|
||||
|
||||
try:
|
||||
# 1. 状态预检 - 彻底解决 NoneType 问题
|
||||
status_raw = item.get('status', '')
|
||||
status = str(status_raw).lower().strip() if status_raw else "unknown"
|
||||
|
||||
conf = item.get('conf') or {}
|
||||
port = conf.get('remote_port')
|
||||
|
||||
# 离线直接判定,严禁继续访问二级接口
|
||||
if status != 'online':
|
||||
add_error("106网站", name, f"设备离线 (当前状态: {status})")
|
||||
save_json(FRPS_DIR, name, item)
|
||||
continue
|
||||
|
||||
if not port:
|
||||
add_error("106网站", name, "配置错误: 缺少 remote_port")
|
||||
continue
|
||||
|
||||
# 2. 只有 Online 才进行二级访问
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}/api/resources/Data/", headers=headers, timeout=10)
|
||||
# 只有 Online 才获取子站点 Token
|
||||
token = get_106_dynamic_token(port)
|
||||
if not token:
|
||||
add_error("106网站", name, "Token获取失败(登录异常)")
|
||||
continue
|
||||
|
||||
headers = {"Authorization": c["primary_auth"], "x-auth": token, "User-Agent": "Mozilla/5.0"}
|
||||
|
||||
# 查找 Data 根目录 (根据类型区分大小写)
|
||||
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||
if res2.status_code != 200:
|
||||
add_error("106网站", name, f"无法打开Data目录 (HTTP {res2.status_code})")
|
||||
continue
|
||||
|
||||
it2 = res2.json().get('items', [])
|
||||
closest_date = find_closest_item(it2, True)
|
||||
if not closest_date:
|
||||
add_error("106网站", name, "Data目录为空")
|
||||
continue
|
||||
best_date = find_closest_item(it2, is_date_level=True)
|
||||
if not best_date or best_date[2] != today_str:
|
||||
add_error("106网站", name, "未找到今日文件夹", best_date[2] if best_date else "N/A")
|
||||
if not best_date: continue
|
||||
|
||||
path_date = closest_date.get('path', '')
|
||||
date_val = path_date.split('/')[-1]
|
||||
date_path = f"{api_root}{best_date[2]}/"
|
||||
|
||||
# 记录日期不对的情况,但尝试继续抓取
|
||||
if date_val != today_str:
|
||||
add_error("106网站", name, "日期非当天", date_val)
|
||||
|
||||
# 3. 访问日期内文件列表
|
||||
res3 = requests.get(f"http://106.75.72.40:{port}/api/resources{path_date}/", headers=headers,
|
||||
timeout=10)
|
||||
# 查找文件夹内最新文件
|
||||
res3 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
|
||||
it3 = res3.json().get('items', [])
|
||||
closest_file = find_closest_item(it3, False)
|
||||
if not closest_file:
|
||||
add_error("106网站", name, "文件夹内无文件", date_val)
|
||||
best_file = find_closest_item(it3, is_date_level=False)
|
||||
if not best_file:
|
||||
add_error("106网站", name, "文件夹内无文件", best_date[2])
|
||||
continue
|
||||
|
||||
# 4. 读取内容并进行 NoneType 防御
|
||||
path_csv = closest_file.get('path', '')
|
||||
res4 = requests.get(f"http://106.75.72.40:{port}/api/resources{path_csv}", headers=headers, timeout=10)
|
||||
file_json = res4.json()
|
||||
file_item = best_file[1]
|
||||
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
|
||||
|
||||
if file_json is None:
|
||||
add_error("106网站", name, "内容接口返回 Null", date_val)
|
||||
continue
|
||||
|
||||
raw_content = file_json.get('content', '')
|
||||
if not raw_content:
|
||||
add_error("106网站", name, "content字段为空", date_val)
|
||||
|
||||
save_json(FRPS_DIR, name, {
|
||||
"status": status,
|
||||
"latest_path": path_csv,
|
||||
"content": process_text_content(raw_content)
|
||||
})
|
||||
# 3. 根据类型下载内容
|
||||
if is_tower_i:
|
||||
# TowerI 模式:使用 raw 接口获取二进制
|
||||
raw_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||
res4 = requests.get(raw_url, headers=headers, timeout=20)
|
||||
if res4.status_code == 200:
|
||||
save_path = os.path.join(FRPS_DIR, f"{name}_{today_str}.bin")
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(res4.content)
|
||||
print(f" ✅ {name} 二进制数据保存成功")
|
||||
else:
|
||||
# Tower_ 模式:使用 resources 接口获取 JSON
|
||||
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
|
||||
res4 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||
file_json = res4.json()
|
||||
raw_content = file_json.get('content', '') if file_json else None
|
||||
if raw_content:
|
||||
save_path = os.path.join(FRPS_DIR, f"{name}_{today_str}.json")
|
||||
with open(save_path, 'w', encoding='utf-8') as f:
|
||||
f.write(process_text_content(raw_content))
|
||||
print(f" ✅ {name} JSON数据保存成功")
|
||||
else:
|
||||
add_error("106网站", name, "文件内容为空", best_date[2])
|
||||
|
||||
except Exception as e:
|
||||
add_error("106网站", name, f"站点级崩溃: {str(e)}")
|
||||
add_error("106网站", name, f"站点处理崩溃: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
add_error("106网站", "全局逻辑", f"主进程崩溃: {str(e)}")
|
||||
|
||||
|
||||
# --- 82 网站逻辑 ---
|
||||
# --- 82 网站逻辑 (保持原样) ---
|
||||
|
||||
def run_82_logic():
|
||||
print("\n>>> 开始处理 82 网站 (Weather)...")
|
||||
@ -253,7 +279,6 @@ def export_to_excel():
|
||||
|
||||
df = pd.DataFrame(error_logs)
|
||||
cols = ["数据来源", "站点/代理名称", "错误原因", "日期偏移量", "最新数据时间", "检查时间"]
|
||||
# 过滤掉 dataframe 中不存在的列,防止报错
|
||||
df = df[[c for c in cols if c in df.columns]]
|
||||
df.to_excel(EXCEL_PATH, index=False)
|
||||
print(f"\n[!] 错误报表已生成至: {EXCEL_PATH} (共 {len(error_logs)} 条)")
|
||||
|
||||
283
2_1banben/app.py
Normal file
283
2_1banben/app.py
Normal file
@ -0,0 +1,283 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import mimetypes
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from flask import Flask, send_from_directory, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
|
||||
# ==============================================================================
|
||||
# ✅ 1. 核心模块引用
|
||||
# ==============================================================================
|
||||
try:
|
||||
from config import Config
|
||||
from extensions import db
|
||||
from models import Device, DeviceHistory
|
||||
# 引入核心爬虫调度
|
||||
from services.core import execute_monitor_task
|
||||
|
||||
try:
|
||||
from services.iot_api import sync_iot_data_service
|
||||
except ImportError:
|
||||
sync_iot_data_service = None
|
||||
|
||||
try:
|
||||
from routes.api import api_bp as device_bp
|
||||
from routes.api import calculate_offset
|
||||
except ImportError:
|
||||
# 兜底逻辑,防止缺失 calculate_offset 导致崩溃
|
||||
def calculate_offset(target_time):
|
||||
return 0
|
||||
|
||||
|
||||
from routes.api import device_bp
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ [启动错误] 模块导入失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# ==============================================================================
|
||||
# 2. 智能路径配置
|
||||
# ==============================================================================
|
||||
RESOURCE_BASE = Config.BASE_DIR
|
||||
INSTANCE_PATH = Config.INSTANCE_DIR
|
||||
|
||||
|
||||
def find_static_folder(base_path):
|
||||
"""
|
||||
全能路径搜寻逻辑,适配 PyInstaller 打包环境
|
||||
"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
if hasattr(sys, '_MEIPASS'):
|
||||
mei_path = os.path.join(sys._MEIPASS, 'web_dist')
|
||||
if os.path.exists(os.path.join(mei_path, 'index.html')):
|
||||
return mei_path
|
||||
internal_path = os.path.join(base_path, '_internal', 'web_dist')
|
||||
if os.path.exists(os.path.join(internal_path, 'index.html')):
|
||||
return internal_path
|
||||
|
||||
path = os.path.join(base_path, 'web_dist')
|
||||
if os.path.exists(os.path.join(path, 'index.html')):
|
||||
return path
|
||||
|
||||
parent_path = os.path.join(os.path.dirname(base_path), 'web_dist')
|
||||
if os.path.exists(os.path.join(parent_path, 'index.html')):
|
||||
return parent_path
|
||||
return path
|
||||
|
||||
|
||||
STATIC_FOLDER = find_static_folder(RESOURCE_BASE)
|
||||
mimetypes.add_type('application/javascript', '.js')
|
||||
mimetypes.add_type('text/css', '.css')
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 核心定时任务逻辑 (深度优化版)
|
||||
# ==============================================================================
|
||||
def auto_monitor_job(app):
|
||||
"""
|
||||
[关键修复]
|
||||
1. 使用 app.app_context() 确保线程中有 Flask 上下文
|
||||
2. 使用 db.session.remove() 强制清理旧连接
|
||||
3. 使用 db.session.merge() 确保对象状态被正确追踪
|
||||
4. 增加详细日志,对比爬虫返回的数据与入库行为
|
||||
"""
|
||||
with app.app_context():
|
||||
# A. 强制清理会话,确保线程获取的是全新的数据库连接
|
||||
db.session.remove()
|
||||
|
||||
tz = pytz.timezone('Asia/Shanghai')
|
||||
now_str = datetime.now(tz).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f"⏰ [定时任务启动] {now_str}")
|
||||
|
||||
if not execute_monitor_task:
|
||||
print("❌ 错误: execute_monitor_task 未定义")
|
||||
return
|
||||
|
||||
try:
|
||||
# B. 执行爬虫
|
||||
task_result = execute_monitor_task()
|
||||
|
||||
if not task_result:
|
||||
print("⚠️ [警告] 爬虫执行完毕,但返回空数据")
|
||||
return
|
||||
|
||||
scraped_list = task_result.get('device_list', [])
|
||||
print(f"📦 [数据获取] 爬取到 {len(scraped_list)} 条设备数据")
|
||||
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
stats = {'updated': 0, 'history': 0}
|
||||
|
||||
for item in scraped_list:
|
||||
d_name = item.get('name')
|
||||
if not d_name: continue
|
||||
|
||||
# --- 1. 数据解包与默认值处理 ---
|
||||
# 显式提取,防止 None 覆盖数据库现有的值(如果业务需要)
|
||||
# 这里假设爬虫返回 None 就是要写入 None,或者空字符串
|
||||
raw_status = item.get('status', '未知')
|
||||
raw_value = item.get('value', '')
|
||||
f_count = item.get('num_files', 0)
|
||||
|
||||
# 时间处理:必须有时间,否则用当前时间
|
||||
target_date = item.get('target_time')
|
||||
if not target_date:
|
||||
target_date = current_time
|
||||
|
||||
raw_json = item.get('raw_json', {})
|
||||
|
||||
# [调试日志] 仅打印第一条或特定的设备,防止刷屏,但能帮你确认数据是否为空
|
||||
# if '0025' in d_name:
|
||||
# print(f" >>> [写入前检查] {d_name}: Value='{raw_value}' | Files={f_count}")
|
||||
|
||||
# --- 2. 数据库操作 (使用 Merge 机制) ---
|
||||
# 先尝试查询
|
||||
device = Device.query.filter_by(name=d_name).first()
|
||||
|
||||
if not device:
|
||||
# 如果不存在,新建对象
|
||||
device = Device(name=d_name, source=item.get('source', '自动爬虫'), install_site="")
|
||||
db.session.add(device)
|
||||
db.session.flush() # 立即获取 ID
|
||||
|
||||
# 更新字段
|
||||
device.status = raw_status
|
||||
device.current_value = raw_value
|
||||
device.latest_time = target_date
|
||||
device.check_time = current_time
|
||||
device.file_count = f_count
|
||||
|
||||
# 计算 Offset
|
||||
try:
|
||||
device.offset = calculate_offset(target_date)
|
||||
except:
|
||||
device.offset = 0
|
||||
|
||||
# JSON 数据合并
|
||||
old_json = {}
|
||||
try:
|
||||
if device.json_data:
|
||||
old_json = json.loads(device.json_data)
|
||||
except:
|
||||
old_json = {}
|
||||
|
||||
if isinstance(raw_json, dict):
|
||||
old_json.update(raw_json)
|
||||
|
||||
device.json_data = json.dumps(old_json, ensure_ascii=False)
|
||||
|
||||
# [核心修复] 使用 merge 告诉 Session "这个对象归你管,请更新它"
|
||||
# 这能解决后台线程中 "DetachedInstanceError" 或更新丢失的问题
|
||||
db.session.merge(device)
|
||||
stats['updated'] += 1
|
||||
|
||||
# --- 3. 写入历史记录 ---
|
||||
history = DeviceHistory(
|
||||
device_id=device.id,
|
||||
status=raw_status,
|
||||
result_data=raw_value,
|
||||
data_time=target_date,
|
||||
file_count=f_count,
|
||||
json_data=device.json_data
|
||||
)
|
||||
db.session.add(history)
|
||||
stats['history'] += 1
|
||||
|
||||
# C. 提交事务
|
||||
db.session.commit()
|
||||
print(f"✅ [入库成功] 设备更新: {stats['updated']} | 历史追加: {stats['history']}")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"❌ [严重异常] 数据写入失败: {e}")
|
||||
# 打印堆栈以便排查
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
# D. 再次清理 Session,防止内存泄漏或污染下一次任务
|
||||
db.session.remove()
|
||||
print(f"{'=' * 50}\n")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. Flask 应用工厂
|
||||
# ==============================================================================
|
||||
def create_app():
|
||||
print(f"🔍 [前端路径锁定] {STATIC_FOLDER}")
|
||||
|
||||
app = Flask(__name__, static_folder=STATIC_FOLDER, instance_path=INSTANCE_PATH)
|
||||
CORS(app)
|
||||
|
||||
if not os.path.exists(app.instance_path):
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 初始化 DB
|
||||
db.init_app(app)
|
||||
|
||||
# 初始化调度器
|
||||
scheduler = APScheduler()
|
||||
scheduler.init_app(app)
|
||||
scheduler.start()
|
||||
|
||||
# --- 添加定时任务 ---
|
||||
# 注意:这里我们传递 [app] 作为参数,确保 job 函数内能获取到 app 上下文
|
||||
scheduler.add_job(
|
||||
id='daily_monitor_task',
|
||||
func=auto_monitor_job,
|
||||
args=[app],
|
||||
trigger='cron',
|
||||
hour=17,
|
||||
minute=00,
|
||||
second=00,
|
||||
misfire_grace_time=3600,
|
||||
timezone=pytz.timezone('Asia/Shanghai')
|
||||
)
|
||||
print(f"📅 定时任务已锁定: 每天北京时间 17:00 执行")
|
||||
|
||||
app.register_blueprint(device_bp)
|
||||
|
||||
@app.route('/api/force_run')
|
||||
def force_run_task():
|
||||
"""手动触发接口:复用同一个 auto_monitor_job 函数,确保逻辑一致"""
|
||||
auto_monitor_job(app)
|
||||
return jsonify({'code': 200, 'msg': '手动触发成功,请查看服务器日志'})
|
||||
|
||||
@app.route('/')
|
||||
def serve_index():
|
||||
try:
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
except Exception:
|
||||
return "Frontend Error", 404
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def serve_static(path):
|
||||
if path.startswith('api'):
|
||||
return jsonify({'code': 404, 'message': 'API endpoint not found'}), 404
|
||||
|
||||
file_path = os.path.join(app.static_folder, path)
|
||||
if os.path.exists(file_path):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
debug_mode = not getattr(sys, 'frozen', False)
|
||||
|
||||
print(f"🚀 服务启动中... 数据库: {app.config['SQLALCHEMY_DATABASE_URI']}")
|
||||
# 注意:use_reloader=False 防止调度器在 Debug 模式下运行两次
|
||||
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)
|
||||
69
2_1banben/config.py
Normal file
69
2_1banben/config.py
Normal file
@ -0,0 +1,69 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def get_base_path():
|
||||
"""获取运行时路径 (兼容打包后的 exe 和开发环境)"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 打包后:exe 所在目录
|
||||
return os.path.dirname(sys.executable)
|
||||
# 开发时:当前文件所在目录
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_static_path():
|
||||
"""获取 web_dist 静态资源路径"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.join(sys._MEIPASS, 'web_dist')
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_dist')
|
||||
|
||||
|
||||
class Config:
|
||||
BASE_DIR = get_base_path()
|
||||
|
||||
# 规范化 instance 目录
|
||||
INSTANCE_DIR = os.path.join(BASE_DIR, 'instance')
|
||||
|
||||
# 确保 instance 目录存在(防止第一次运行时报错)
|
||||
if not os.path.exists(INSTANCE_DIR):
|
||||
try:
|
||||
os.makedirs(INSTANCE_DIR)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# [修改] 绝对路径拼接,并强制将 Windows 的 \ 转换为 /,避免 SQLite URI 报错
|
||||
# 最终结果类似: sqlite:///D:/project/instance/monitor_data.db
|
||||
_db_path = os.path.join(INSTANCE_DIR, "monitor_data.db").replace('\\', '/')
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{_db_path}'
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# --- 定时任务配置 ---
|
||||
SCHEDULER_API_ENABLED = True
|
||||
SCHEDULER_TIMEZONE = "Asia/Shanghai"
|
||||
|
||||
# --- 爬虫配置 ---
|
||||
CRAWLER_CONFIG = {
|
||||
"106": {
|
||||
"base_url": "http://106.75.72.40:7500/api/proxy/tcp",
|
||||
"primary_auth": "Basic YWRtaW46bGljYWhr",
|
||||
"login_payload": {"username": "admin", "password": "licahk", "recaptcha": ""}
|
||||
},
|
||||
"82": {
|
||||
"base_url": "http://82.156.1.111/weather/php",
|
||||
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
|
||||
}
|
||||
}
|
||||
|
||||
# --- IoT 物联网卡接口配置 ---
|
||||
IOT_BASE_URL = "https://iot.huskyiot.cn"
|
||||
IOT_APP_ID = "44aQHTpx"
|
||||
IOT_SECRET = "26833abf8786167a5cff5355cfc249981985124a"
|
||||
IOT_USERNAME = "yrsy"
|
||||
IOT_PASSWORD = "123456789"
|
||||
IOT_URL_LOGIN = "/iot-api/system/auth/v1/get/token"
|
||||
IOT_URL_PAGE = "/iot-api/platform/v1/card-info/query/page"
|
||||
IOT_URL_DETAIL = "/iot-api/platform/v1/card-info/query/batch-card-detail"
|
||||
|
||||
# [Debug] 打印路径确认
|
||||
print(f"配置文件已加载,数据库路径: {SQLALCHEMY_DATABASE_URI}")
|
||||
9
2_1banben/extensions.py
Normal file
9
2_1banben/extensions.py
Normal file
@ -0,0 +1,9 @@
|
||||
#extensions.py
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
|
||||
# 这里只创建对象,不绑定 app
|
||||
db = SQLAlchemy()
|
||||
cors = CORS()
|
||||
scheduler = APScheduler()
|
||||
89
2_1banben/models.py
Normal file
89
2_1banben/models.py
Normal file
@ -0,0 +1,89 @@
|
||||
# models.py
|
||||
from datetime import datetime
|
||||
from extensions import db
|
||||
|
||||
|
||||
class Device(db.Model):
|
||||
__tablename__ = 'devices'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, index=True)
|
||||
source = db.Column(db.String(50))
|
||||
|
||||
# 快照字段(爬虫更新)
|
||||
status = db.Column(db.String(50))
|
||||
current_value = db.Column(db.String(200))
|
||||
latest_time = db.Column(db.String(50))
|
||||
json_data = db.Column(db.Text)
|
||||
check_time = db.Column(db.String(50))
|
||||
reason = db.Column(db.String(255))
|
||||
offset = db.Column(db.String(50))
|
||||
|
||||
# ✅ 新增字段:文件数量
|
||||
file_count = db.Column(db.Integer, default=0)
|
||||
|
||||
# 手动录入字段(受保护,run_monitor 不主动覆盖)
|
||||
install_site = db.Column(db.String(100), default="")
|
||||
is_maintaining = db.Column(db.Boolean, default=False)
|
||||
is_hidden = db.Column(db.Boolean, default=False)
|
||||
|
||||
# 白名单字段 (根据上下文可能存在,补全以防万一)
|
||||
is_whitelist = db.Column(db.Boolean, default=False)
|
||||
|
||||
def to_dict(self):
|
||||
# 统一状态映射逻辑
|
||||
api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online'
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'source': self.source,
|
||||
'latest_time': self.latest_time,
|
||||
'status': api_status,
|
||||
'status_text': self.status,
|
||||
'value': self.current_value,
|
||||
'reason': self.reason,
|
||||
'install_site': self.install_site or '',
|
||||
'is_maintaining': self.is_maintaining,
|
||||
'is_hidden': self.is_hidden,
|
||||
'is_whitelist': self.is_whitelist,
|
||||
'offset': self.offset,
|
||||
'file_count': self.file_count # ✅ 返回给前端
|
||||
}
|
||||
|
||||
|
||||
class DeviceHistory(db.Model):
|
||||
__tablename__ = 'device_history'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
device_id = db.Column(db.Integer, db.ForeignKey('devices.id'))
|
||||
|
||||
data_time = db.Column(db.String(50))
|
||||
status = db.Column(db.String(50))
|
||||
result_data = db.Column(db.String(200), default="")
|
||||
json_data = db.Column(db.Text)
|
||||
file_path = db.Column(db.String(255))
|
||||
|
||||
# ✅ 新增字段:历史记录文件数量
|
||||
file_count = db.Column(db.Integer, default=0)
|
||||
|
||||
recorded_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
|
||||
class MaintenanceLog(db.Model):
|
||||
__tablename__ = 'maintenance_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
device_name = db.Column(db.String(100), nullable=False)
|
||||
engineer = db.Column(db.String(50))
|
||||
location = db.Column(db.String(100))
|
||||
content = db.Column(db.Text)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'device_name': self.device_name,
|
||||
'engineer': self.engineer or '',
|
||||
'location': self.location or '',
|
||||
'content': self.content,
|
||||
'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
2
2_1banben/routes/__init__.py
Normal file
2
2_1banben/routes/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# routes/__init__.py
|
||||
# 这是一个空文件,用于将 routes 文件夹标识为 Python 包。
|
||||
696
2_1banben/routes/api.py
Normal file
696
2_1banben/routes/api.py
Normal file
@ -0,0 +1,696 @@
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import desc, or_
|
||||
from extensions import db
|
||||
from models import Device, DeviceHistory, MaintenanceLog
|
||||
|
||||
# =========================================================
|
||||
# 模块动态导入 (防止循环引用或缺失报错)
|
||||
# =========================================================
|
||||
try:
|
||||
from services.core import execute_monitor_task
|
||||
except ImportError:
|
||||
execute_monitor_task = None
|
||||
|
||||
try:
|
||||
from services.iot_api import sync_iot_data_service
|
||||
except ImportError:
|
||||
sync_iot_data_service = None
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 0. 核心算法区:数据质量分析与辅助函数
|
||||
# =========================================================
|
||||
|
||||
def calculate_offset(latest_time_str):
|
||||
"""
|
||||
计算时间滞后天数
|
||||
用于前端展示设备数据是否过时
|
||||
"""
|
||||
if not latest_time_str or latest_time_str == "N/A":
|
||||
return "从未同步"
|
||||
try:
|
||||
# 兼容处理 2026_01_13 和 2026-01-13 格式
|
||||
clean = str(latest_time_str).split()[0].replace('_', '-')
|
||||
target = datetime.strptime(clean, "%Y-%m-%d").date()
|
||||
diff = (datetime.now().date() - target).days
|
||||
return "当天" if diff == 0 else f"滞后 {diff} 天"
|
||||
except:
|
||||
return "时间解析失败"
|
||||
|
||||
|
||||
def check_data_quality(content_data, source_type, data_time_str=None):
|
||||
"""
|
||||
数据质量分析算法 (融合版:旧版核心规则 + 新版夜间/IoT过滤)
|
||||
用于判断设备状态颜色 (绿色ok/黄色warning/红色error)
|
||||
"""
|
||||
if not content_data:
|
||||
return 'ok'
|
||||
|
||||
# 1. IoT 卡不需要检查数据质量
|
||||
if str(source_type) == 'iot_card':
|
||||
return 'ok'
|
||||
|
||||
# 2. 夜间免打扰逻辑 (08:00 - 17:00 之外不报错)
|
||||
if data_time_str and data_time_str != 'N/A':
|
||||
try:
|
||||
clean_time = str(data_time_str).replace('_', '-')
|
||||
dt = None
|
||||
try:
|
||||
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
try:
|
||||
dt = datetime.strptime(clean_time, "%Y-%m-%d %H:%M")
|
||||
except:
|
||||
pass
|
||||
|
||||
if dt and (dt.hour < 8 or dt.hour >= 17):
|
||||
return 'ok'
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3. 数据异常判断逻辑
|
||||
status = 'ok'
|
||||
source_str = str(source_type)
|
||||
|
||||
# --- Type A: 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)
|
||||
|
||||
if 'OSIFBeta' in text_content:
|
||||
lines = text_content.split('\n') if '\n' in text_content else [text_content]
|
||||
|
||||
for line in lines:
|
||||
if 'OSIFBeta' not in line:
|
||||
continue
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 10:
|
||||
continue
|
||||
|
||||
try:
|
||||
int_time = int(parts[2])
|
||||
if int_time >= 66534:
|
||||
data_points = []
|
||||
for p in parts[3:]:
|
||||
try:
|
||||
data_points.append(float(p))
|
||||
except:
|
||||
pass
|
||||
|
||||
if not data_points:
|
||||
continue
|
||||
|
||||
for val in data_points:
|
||||
if val < 100:
|
||||
return 'error'
|
||||
|
||||
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
|
||||
except:
|
||||
continue
|
||||
except Exception:
|
||||
return 'ok'
|
||||
|
||||
# --- Type B: 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 specs and isinstance(specs, list):
|
||||
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'
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def save_iot_cards_to_db(card_list):
|
||||
"""
|
||||
[核心修复] IoT数据入库逻辑 - 增量更新模式
|
||||
这里负责将 iot_api.py 获取到的新字段保存到数据库的 JSON 字段中
|
||||
"""
|
||||
if not card_list: return 0, None
|
||||
update_count = 0
|
||||
|
||||
try:
|
||||
for card in card_list:
|
||||
iccid = card.get('iccid') or card.get('card_id')
|
||||
if not iccid: continue
|
||||
|
||||
# 1. 查找是否存在该 SIM 卡记录
|
||||
sim_record = Device.query.filter_by(name=iccid, source='iot_card').first()
|
||||
|
||||
old_json = {}
|
||||
|
||||
if not sim_record:
|
||||
sim_record = Device(name=iccid, source='iot_card', install_site="IoT库")
|
||||
db.session.add(sim_record)
|
||||
db.session.flush()
|
||||
else:
|
||||
try:
|
||||
if sim_record.json_data:
|
||||
old_json = json.loads(sim_record.json_data)
|
||||
except:
|
||||
old_json = {}
|
||||
|
||||
# 2. 准备需要更新的 API 数据
|
||||
api_updates = {
|
||||
"iccid": iccid,
|
||||
"usedTraffic": str(card.get('usedTraffic') or '0'),
|
||||
"stopDate": card.get('stopDate', 'N/A'),
|
||||
"cardStatus": card.get('cardStatus'),
|
||||
"tag": card.get('tag', ''),
|
||||
|
||||
# === [新增] 这里保存刚才在 iot_api.py 里生成的中文状态描述 ===
|
||||
"statusDesc": card.get('statusDesc', '未知')
|
||||
# ========================================================
|
||||
}
|
||||
|
||||
# 3. 合并数据 (保留 is_whitelist)
|
||||
old_json.update(api_updates)
|
||||
|
||||
if 'is_whitelist' not in old_json:
|
||||
old_json['is_whitelist'] = False
|
||||
|
||||
# 4. 更新数据库字段
|
||||
sim_record.status = str(card.get('cardStatus', ''))
|
||||
sim_record.json_data = json.dumps(old_json, ensure_ascii=False)
|
||||
sim_record.check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
update_count += 1
|
||||
|
||||
return update_count, None
|
||||
except Exception as e:
|
||||
return 0, str(e)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 1. 基础接口 (认证 & 概览)
|
||||
# =========================================================
|
||||
|
||||
@api_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if username == 'admin' and password == 'licahk':
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': '登录成功',
|
||||
'token': 'super-admin-token-2026',
|
||||
'user': {'username': 'admin', 'role': 'administrator'}
|
||||
})
|
||||
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
|
||||
|
||||
|
||||
@api_bp.route('/devices_overview', methods=['GET'])
|
||||
def devices_overview():
|
||||
try:
|
||||
# A. 获取 IoT卡表
|
||||
iot_records = Device.query.filter_by(source='iot_card').all()
|
||||
iot_map = {}
|
||||
for rec in iot_records:
|
||||
try:
|
||||
j = json.loads(rec.json_data)
|
||||
iot_map[rec.name] = j
|
||||
except:
|
||||
pass
|
||||
|
||||
# B. 获取 真实设备
|
||||
devices = Device.query.filter(Device.source != 'iot_card').all()
|
||||
data_list = []
|
||||
|
||||
for d in devices:
|
||||
# 关键:d.to_dict() 在 models.py 中应包含 file_count
|
||||
item = d.to_dict()
|
||||
|
||||
# 强制格式化时间
|
||||
raw_time = d.latest_time
|
||||
if raw_time:
|
||||
if hasattr(raw_time, 'strftime'):
|
||||
item['latest_time'] = raw_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
s = str(raw_time).strip()
|
||||
if '_' in s and ':' not in s:
|
||||
item['latest_time'] = s.replace('_', '-') + " 00:00:00"
|
||||
else:
|
||||
item['latest_time'] = s
|
||||
|
||||
parsed_content = {}
|
||||
if d.json_data:
|
||||
try:
|
||||
parsed_content = json.loads(d.json_data)
|
||||
except:
|
||||
pass
|
||||
|
||||
# --- 绑定逻辑 ---
|
||||
bound_iccid = parsed_content.get('bound_iccid')
|
||||
|
||||
item['usedTraffic'] = None
|
||||
item['stopDate'] = None
|
||||
item['statusDesc'] = None # 初始化字段
|
||||
item['isBound'] = False
|
||||
item['bound_iccid'] = bound_iccid
|
||||
item['is_whitelist'] = False
|
||||
|
||||
# 如果有绑定,注入卡片信息
|
||||
if bound_iccid and bound_iccid in iot_map:
|
||||
card_info = iot_map[bound_iccid]
|
||||
item['usedTraffic'] = card_info.get('usedTraffic')
|
||||
item['stopDate'] = card_info.get('stopDate')
|
||||
item['is_whitelist'] = card_info.get('is_whitelist', False)
|
||||
|
||||
# === [新增] 将绑定的卡片状态描述传给前端 ===
|
||||
item['statusDesc'] = card_info.get('statusDesc')
|
||||
# ======================================
|
||||
|
||||
item['isBound'] = True
|
||||
|
||||
item['data_quality'] = check_data_quality(parsed_content, d.source, d.latest_time)
|
||||
|
||||
data_list.append(item)
|
||||
|
||||
# C. IoT卡表数据 (用于卡池管理界面)
|
||||
for rec in iot_records:
|
||||
item = rec.to_dict()
|
||||
try:
|
||||
j = json.loads(rec.json_data)
|
||||
except:
|
||||
j = {}
|
||||
|
||||
item['usedTraffic'] = j.get('usedTraffic', '0')
|
||||
item['stopDate'] = j.get('stopDate', '')
|
||||
item['is_whitelist'] = j.get('is_whitelist', False)
|
||||
|
||||
# === [新增] 将卡池列表中的状态描述传给前端 ===
|
||||
item['statusDesc'] = j.get('statusDesc', '未知')
|
||||
# =======================================
|
||||
|
||||
item['isOrphanIoT'] = True
|
||||
item['source'] = 'iot_card'
|
||||
data_list.append(item)
|
||||
|
||||
return jsonify({'code': 200, 'data': data_list})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 2. 历史数据接口
|
||||
# =========================================================
|
||||
|
||||
@api_bp.route('/device_data_by_date', methods=['GET'])
|
||||
def device_data_by_date():
|
||||
name = request.args.get('name')
|
||||
date_str = request.args.get('date')
|
||||
|
||||
if not name or not date_str:
|
||||
return jsonify({'code': 400, 'message': 'Missing name or date'}), 400
|
||||
|
||||
device = Device.query.filter_by(name=name).first()
|
||||
if not device:
|
||||
return jsonify({'code': 404, 'message': 'Device not found'}), 404
|
||||
|
||||
content = None
|
||||
query_date = date_str.replace('_', '-')
|
||||
|
||||
history_record = DeviceHistory.query.filter(
|
||||
DeviceHistory.device_id == device.id,
|
||||
DeviceHistory.data_time.like(f"{query_date}%")
|
||||
).order_by(desc(DeviceHistory.id)).first()
|
||||
|
||||
if history_record:
|
||||
content = history_record.json_data
|
||||
elif device.latest_time and device.latest_time.startswith(query_date):
|
||||
content = device.json_data
|
||||
|
||||
if content:
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'name': device.name,
|
||||
'source': device.source,
|
||||
'content': content
|
||||
})
|
||||
return jsonify({'code': 404, 'message': 'No data for this date'}), 404
|
||||
|
||||
|
||||
@api_bp.route('/device_data_by_date_stub', methods=['GET'])
|
||||
def device_data_by_date_stub():
|
||||
return device_data_by_date()
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 3. 核心控制接口 (检测 & 写入)
|
||||
# =========================================================
|
||||
|
||||
@api_bp.route('/run_monitor', methods=['POST'])
|
||||
def run_monitor():
|
||||
msg_list = []
|
||||
|
||||
try:
|
||||
# --- A. 执行爬虫并入库 ---
|
||||
if execute_monitor_task:
|
||||
task_result = execute_monitor_task()
|
||||
if task_result:
|
||||
scraped_list = task_result.get('device_list', [])
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
count_crawler = 0
|
||||
for item in scraped_list:
|
||||
d_name = item.get('name')
|
||||
if not d_name: continue
|
||||
|
||||
d_raw = item.get('raw_json', {})
|
||||
source = item.get('source', '')
|
||||
target_time = item.get('target_time')
|
||||
|
||||
if '106' in str(source):
|
||||
try:
|
||||
path_str = d_raw.get('path', '')
|
||||
match = re.search(r'/Data/(\d{4}_\d{2}_\d{2})/\w+_(\d{2}_\d{2}_\d{2})\.csv', path_str)
|
||||
if match:
|
||||
date_part = match.group(1).replace('_', '-')
|
||||
time_part = match.group(2).replace('_', ':')
|
||||
target_time = f"{date_part} {time_part}"
|
||||
except:
|
||||
pass
|
||||
|
||||
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()
|
||||
|
||||
if device.source == 'iot_card':
|
||||
device.source = source
|
||||
|
||||
device.status = item.get('status')
|
||||
device.current_value = item.get('value')
|
||||
device.latest_time = target_time
|
||||
device.check_time = current_time
|
||||
|
||||
# ✅ [核心修改] 获取爬虫返回的文件数量并保存
|
||||
f_count = item.get('num_files', 0)
|
||||
device.file_count = f_count
|
||||
|
||||
old_json = {}
|
||||
try:
|
||||
if device.json_data:
|
||||
old_json = json.loads(device.json_data)
|
||||
except:
|
||||
old_json = {}
|
||||
|
||||
new_json = d_raw if isinstance(d_raw, dict) else item.get('raw_json', {})
|
||||
if isinstance(new_json, dict):
|
||||
old_json.update(new_json)
|
||||
|
||||
device.json_data = json.dumps(old_json, ensure_ascii=False)
|
||||
device.offset = calculate_offset(device.latest_time)
|
||||
|
||||
# ✅ [核心修改] 写入历史记录时包含 file_count
|
||||
new_history = DeviceHistory(
|
||||
device_id=device.id,
|
||||
status=item.get('status'),
|
||||
result_data=item.get('value'),
|
||||
data_time=target_time,
|
||||
json_data=device.json_data,
|
||||
file_count=f_count # 确保历史数据也记录文件数
|
||||
)
|
||||
db.session.add(new_history)
|
||||
count_crawler += 1
|
||||
|
||||
msg_list.append(f"爬虫更新: {count_crawler}")
|
||||
else:
|
||||
msg_list.append("爬虫无数据")
|
||||
|
||||
# --- B. 执行 IoT 同步 (写入数据库) ---
|
||||
if sync_iot_data_service:
|
||||
iot_list = sync_iot_data_service()
|
||||
# 复用 save_iot_cards_to_db 保存包含 statusDesc 的新数据
|
||||
c, e = save_iot_cards_to_db(iot_list)
|
||||
if e:
|
||||
msg_list.append(f"IoT错: {e}")
|
||||
else:
|
||||
msg_list.append(f"IoT更新: {c}")
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200, 'message': " | ".join(msg_list)})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 4. 白名单、绑定与设备管理
|
||||
# =========================================================
|
||||
|
||||
@api_bp.route('/toggle_whitelist', methods=['POST'])
|
||||
def toggle_whitelist():
|
||||
data = request.get_json()
|
||||
iccid = data.get('iccid')
|
||||
is_whitelist = data.get('is_whitelist')
|
||||
|
||||
sim_record = Device.query.filter_by(name=iccid, source='iot_card').first()
|
||||
if not sim_record:
|
||||
return jsonify({'code': 404, 'message': '未找到该卡片'})
|
||||
|
||||
try:
|
||||
j = json.loads(sim_record.json_data)
|
||||
j['is_whitelist'] = is_whitelist
|
||||
sim_record.json_data = json.dumps(j, ensure_ascii=False)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200, 'message': '设置成功'})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@api_bp.route('/sync_iot_cards', methods=['POST'])
|
||||
def sync_iot_cards():
|
||||
if not sync_iot_data_service: return jsonify({'code': 500, 'message': '服务缺失'}), 500
|
||||
try:
|
||||
iot_list = sync_iot_data_service()
|
||||
c, e = save_iot_cards_to_db(iot_list)
|
||||
if e: return jsonify({'code': 500, 'message': e}), 500
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200, 'message': f'更新{c}张卡', 'data': iot_list})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/bind_device_card', methods=['POST'])
|
||||
def bind_device_card():
|
||||
data = request.get_json()
|
||||
target = Device.query.filter_by(name=data.get('device_name')).first()
|
||||
if not target: return jsonify({'code': 404, 'message': '找不到设备'})
|
||||
try:
|
||||
d_json = {}
|
||||
try:
|
||||
d_json = json.loads(target.json_data)
|
||||
except:
|
||||
pass
|
||||
d_json['bound_iccid'] = data.get('iccid')
|
||||
target.json_data = json.dumps(d_json, ensure_ascii=False)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200, 'message': '绑定成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@api_bp.route('/add_device', methods=['POST'])
|
||||
def add_device():
|
||||
data = request.get_json()
|
||||
try:
|
||||
new_device = Device(
|
||||
name=data.get('name'),
|
||||
install_site=data.get('site', ''),
|
||||
source='manual',
|
||||
status='offline',
|
||||
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})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@api_bp.route('/update_site', methods=['POST'])
|
||||
def update_site():
|
||||
d = Device.query.filter_by(name=request.json.get('name')).first()
|
||||
if d:
|
||||
d.install_site = request.json.get('site')
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404})
|
||||
|
||||
|
||||
@api_bp.route('/toggle_maintenance', methods=['POST'])
|
||||
def toggle_maintenance():
|
||||
d = Device.query.filter_by(name=request.json.get('name')).first()
|
||||
if d:
|
||||
d.is_maintaining = request.json.get('is_maintaining')
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404})
|
||||
|
||||
|
||||
@api_bp.route('/toggle_hidden', methods=['POST'])
|
||||
def toggle_hidden():
|
||||
d = Device.query.filter_by(name=request.json.get('name')).first()
|
||||
if d:
|
||||
d.is_hidden = request.json.get('is_hidden')
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404})
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 5. 日志管理接口 (CRUD)
|
||||
# =========================================================
|
||||
|
||||
@api_bp.route('/logs/list', methods=['GET'])
|
||||
def get_logs_list():
|
||||
keyword = request.args.get('keyword', '')
|
||||
query = MaintenanceLog.query
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
query = query.filter(or_(
|
||||
MaintenanceLog.device_name.like(kw),
|
||||
MaintenanceLog.content.like(kw),
|
||||
MaintenanceLog.engineer.like(kw)
|
||||
))
|
||||
logs = query.order_by(MaintenanceLog.timestamp.desc()).all()
|
||||
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
|
||||
|
||||
|
||||
@api_bp.route('/logs/add', methods=['POST'])
|
||||
def add_log_entry():
|
||||
data = request.get_json()
|
||||
try:
|
||||
new_log = MaintenanceLog(
|
||||
device_name=data.get('device_name', ''),
|
||||
engineer=data.get('engineer', ''),
|
||||
location=data.get('location', ''),
|
||||
content=data.get('content', '')
|
||||
)
|
||||
db.session.add(new_log)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@api_bp.route('/logs/update', methods=['POST'])
|
||||
def update_log_entry():
|
||||
data = request.get_json()
|
||||
log = MaintenanceLog.query.get(data.get('id'))
|
||||
if not log: return jsonify({'code': 404})
|
||||
try:
|
||||
log.device_name = data.get('device_name', log.device_name)
|
||||
log.content = data.get('content', log.content)
|
||||
log.engineer = data.get('engineer', log.engineer)
|
||||
log.location = data.get('location', log.location)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500})
|
||||
|
||||
|
||||
@api_bp.route('/logs/delete', methods=['POST'])
|
||||
def delete_log_entry():
|
||||
data = request.get_json()
|
||||
log = MaintenanceLog.query.get(data.get('id'))
|
||||
if log:
|
||||
db.session.delete(log)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404})
|
||||
|
||||
|
||||
@api_bp.route('/device_history_list', methods=['GET'])
|
||||
def get_device_history_list():
|
||||
try:
|
||||
name = request.args.get('name')
|
||||
page = int(request.args.get('page', 1))
|
||||
limit = int(request.args.get('limit', 10))
|
||||
|
||||
if not name:
|
||||
return jsonify({'code': 400, 'message': '缺少设备名称'})
|
||||
|
||||
# 1. 找到设备ID
|
||||
device = Device.query.filter_by(name=name).first()
|
||||
if not device:
|
||||
return jsonify({'code': 404, 'message': '设备不存在'})
|
||||
|
||||
# 2. 查询历史记录 (按时间倒序)
|
||||
query = DeviceHistory.query.filter_by(device_id=device.id).order_by(desc(DeviceHistory.data_time))
|
||||
|
||||
# 3. 获取总数
|
||||
total = query.count()
|
||||
|
||||
# 4. 分页切片
|
||||
history_list = query.offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
# 5. 格式化返回数据
|
||||
data = []
|
||||
for h in history_list:
|
||||
# 简单处理日期格式,只取日期部分,或者保留完整时间视需求而定
|
||||
# 这里假设 data_time 格式为 "YYYY-MM-DD HH:MM:SS" 或 "YYYY_MM_DD..."
|
||||
date_str = h.data_time
|
||||
if not date_str:
|
||||
date_str = h.recorded_at.strftime("%Y-%m-%d %H:%M:%S") if h.recorded_at else "未知"
|
||||
|
||||
data.append({
|
||||
'date': date_str,
|
||||
'count': h.file_count or 0
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'data': data,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
27
2_1banben/routes/web.py
Normal file
27
2_1banben/routes/web.py
Normal file
@ -0,0 +1,27 @@
|
||||
import os
|
||||
from flask import Blueprint, send_from_directory
|
||||
# 👇 确保 config.py 在根目录,且能被引用
|
||||
from config import get_static_path
|
||||
|
||||
web_bp = Blueprint('web', __name__)
|
||||
|
||||
@web_bp.route('/')
|
||||
def index():
|
||||
"""访问根路径时,返回 web_dist/index.html"""
|
||||
try:
|
||||
return send_from_directory(get_static_path(), 'index.html')
|
||||
except Exception as e:
|
||||
return f"前端资源未找到,请确认 web_dist 文件夹是否存在。错误信息: {e}", 404
|
||||
|
||||
@web_bp.route('/<path:path>')
|
||||
def static_files(path):
|
||||
"""访问 /css, /js 等静态资源"""
|
||||
static_folder = get_static_path()
|
||||
file_path = os.path.join(static_folder, path)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
return send_from_directory(static_folder, path)
|
||||
|
||||
# 路由回退:解决 Vue History 模式刷新 404 问题
|
||||
# 如果找不到文件,就返回 index.html,让 Vue 路由去处理
|
||||
return send_from_directory(static_folder, 'index.html')
|
||||
2
2_1banben/services/__init__.py
Normal file
2
2_1banben/services/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# services/__init__.py
|
||||
# 这是一个空文件,用于将 services 文件夹标识为 Python 包。
|
||||
104
2_1banben/services/core.py
Normal file
104
2_1banben/services/core.py
Normal file
@ -0,0 +1,104 @@
|
||||
# services/core.py
|
||||
import logging
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
# ==============================================================================
|
||||
# 1. 动态导入模块
|
||||
# ==============================================================================
|
||||
try:
|
||||
from .crawler_106 import run_106_logic
|
||||
except ImportError as e:
|
||||
print(f"⚠️ [系统警告] 无法导入 crawler_106: {e}")
|
||||
|
||||
|
||||
def run_106_logic():
|
||||
return []
|
||||
|
||||
try:
|
||||
from .crawler_82 import run_82_logic
|
||||
except ImportError as e:
|
||||
print(f"⚠️ [系统警告] 无法导入 crawler_82: {e}")
|
||||
|
||||
|
||||
def run_82_logic():
|
||||
return []
|
||||
|
||||
# 全局任务锁
|
||||
task_lock = threading.Lock()
|
||||
|
||||
|
||||
def execute_monitor_task():
|
||||
"""
|
||||
执行所有爬虫,返回一个大列表:
|
||||
{'device_list': [item1, item2...], 'target_time': '...'}
|
||||
"""
|
||||
# 1. 锁机制:防止任务重复运行
|
||||
if task_lock.locked():
|
||||
logging.warning(">>> 任务正在运行中,跳过")
|
||||
print(">>> ⚠️ [调度] 任务正在运行中,本次请求跳过")
|
||||
return None
|
||||
|
||||
with task_lock:
|
||||
start_time = datetime.now()
|
||||
logging.info(">>> 开始执行监控任务...")
|
||||
print(f"--- [任务开始] {start_time.strftime('%H:%M:%S')} ---")
|
||||
|
||||
all_results = []
|
||||
|
||||
# ==========================
|
||||
# 2. 执行 106 爬虫
|
||||
# ==========================
|
||||
try:
|
||||
print(f">>> [106爬虫] 启动...")
|
||||
list_106 = run_106_logic()
|
||||
|
||||
if list_106:
|
||||
count = len(list_106)
|
||||
print(f"✅ 106爬虫获取数据: {count} 条")
|
||||
all_results.extend(list_106)
|
||||
else:
|
||||
print("⚠️ 106爬虫运行完成,但未返回任何数据 (空列表)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 106爬虫执行严重失败: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# ==========================
|
||||
# 3. 执行 82 爬虫
|
||||
# ==========================
|
||||
try:
|
||||
print(f">>> [82爬虫] 启动...")
|
||||
list_82 = run_82_logic()
|
||||
|
||||
if list_82:
|
||||
print(f"✅ 82爬虫获取数据: {len(list_82)} 条")
|
||||
|
||||
# 🛠️ [补全] 82爬虫没有文件数概念,手动补0,防止入库报错
|
||||
for item in list_82:
|
||||
if 'num_files' not in item:
|
||||
item['num_files'] = 0
|
||||
if 'status' not in item:
|
||||
item['status'] = 'Unknown'
|
||||
|
||||
all_results.extend(list_82)
|
||||
else:
|
||||
print("⚠️ 82爬虫运行完成,但未返回数据")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 82爬虫执行严重失败: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# ==========================
|
||||
# 4. 汇总返回
|
||||
# ==========================
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logging.info(f">>> 任务完成,共获取 {len(all_results)} 条数据")
|
||||
print(f"--- [任务结束] 总耗时: {duration:.2f}秒 | 总计获取: {len(all_results)} 台设备 ---")
|
||||
|
||||
return {
|
||||
'device_list': all_results,
|
||||
'target_time': None, # 具体时间已在 item['target_time'] 里
|
||||
'temp_file_path': None # 废弃旧逻辑
|
||||
}
|
||||
207
2_1banben/services/crawler_106.py
Normal file
207
2_1banben/services/crawler_106.py
Normal file
@ -0,0 +1,207 @@
|
||||
# services/crawler_106.py
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from config import Config
|
||||
|
||||
CONFIG = Config.CRAWLER_CONFIG["106"]
|
||||
|
||||
|
||||
def get_temp_dir():
|
||||
"""获取临时文件存储目录"""
|
||||
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
temp_dir = os.path.join(base_dir, 'instance', 'temp')
|
||||
if not os.path.exists(temp_dir):
|
||||
os.makedirs(temp_dir)
|
||||
return temp_dir
|
||||
|
||||
|
||||
def get_106_dynamic_token(port):
|
||||
"""获取动态登录 Token"""
|
||||
try:
|
||||
login_url = f"http://106.75.72.40:{port}/api/login"
|
||||
resp = requests.post(login_url, json=CONFIG["login_payload"], timeout=10)
|
||||
return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def find_closest_item(items, is_date_level=True):
|
||||
"""
|
||||
在列表中找到与当前日期最接近的文件夹或文件
|
||||
"""
|
||||
if not items or not isinstance(items, list): return None
|
||||
today = datetime.now()
|
||||
scored_items = []
|
||||
|
||||
for item in items:
|
||||
name_val = item.get('name', '')
|
||||
path_val = item.get('path', '')
|
||||
# 如果是日期层级,名字通常是 2026_02_08 这种格式
|
||||
target_str = name_val if name_val else path_val.split('/')[-1]
|
||||
|
||||
try:
|
||||
if is_date_level:
|
||||
# 解析文件夹日期格式: YYYY_MM_DD
|
||||
current_date = datetime.strptime(target_str, "%Y_%m_%d")
|
||||
else:
|
||||
# 解析文件修改时间
|
||||
mod_str = item.get('modified', '')
|
||||
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
|
||||
|
||||
# 计算与当前时间的差距
|
||||
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
|
||||
scored_items.append((diff, item, target_str))
|
||||
except:
|
||||
continue
|
||||
|
||||
if not scored_items: return None
|
||||
# 按时间差排序,取最小的
|
||||
scored_items.sort(key=lambda x: x[0])
|
||||
return scored_items[0]
|
||||
|
||||
|
||||
def run_106_logic():
|
||||
"""
|
||||
106 爬虫主逻辑
|
||||
返回 result_list, 每个元素是一个字典
|
||||
"""
|
||||
results = []
|
||||
print(">>> [106爬虫] 启动...")
|
||||
main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
||||
|
||||
try:
|
||||
# 0. 获取代理列表 (设备列表)
|
||||
resp = requests.get(CONFIG["base_url"], headers=main_headers, timeout=20)
|
||||
proxies = resp.json().get('proxies', [])
|
||||
|
||||
for item in proxies:
|
||||
name = item.get('name', '')
|
||||
# 过滤规则:必须以 _data 结尾
|
||||
if not name.lower().endswith('_data'): continue
|
||||
|
||||
name_upper = name.upper()
|
||||
is_tower_underscore = "TOWER_" in name_upper
|
||||
is_tower_i = "TOWER" in name_upper and not is_tower_underscore
|
||||
|
||||
# 过滤规则:必须包含 TOWER 相关标识
|
||||
if not (is_tower_underscore or is_tower_i): continue
|
||||
|
||||
# --- 构建基础数据包 ---
|
||||
# 默认使用标准当前时间作为兜底,防止后续步骤失败时时间为空
|
||||
current_standard_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
data_packet = {
|
||||
'source': '106网站',
|
||||
'name': name,
|
||||
'status': '正常',
|
||||
'value': '',
|
||||
'target_time': current_standard_time,
|
||||
'raw_json': {},
|
||||
'temp_file': None,
|
||||
'num_files': 0
|
||||
}
|
||||
|
||||
# 检查在线状态
|
||||
if str(item.get('status')).lower() != 'online':
|
||||
data_packet['status'] = '离线'
|
||||
data_packet['value'] = f"状态: {item.get('status')}"
|
||||
results.append(data_packet)
|
||||
continue
|
||||
|
||||
try:
|
||||
# 获取端口和 Token
|
||||
port = item.get('conf', {}).get('remote_port')
|
||||
token = get_106_dynamic_token(port)
|
||||
if not token:
|
||||
data_packet['status'] = '异常'
|
||||
data_packet['value'] = "Token获取失败"
|
||||
results.append(data_packet)
|
||||
continue
|
||||
|
||||
headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token}
|
||||
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
|
||||
|
||||
# --- 1. 获取日期文件夹列表 ---
|
||||
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||
best_date = find_closest_item(res1.json().get('items', []), True)
|
||||
|
||||
if not best_date:
|
||||
data_packet['value'] = "未找到任何日期文件夹"
|
||||
results.append(data_packet)
|
||||
continue
|
||||
|
||||
# ==============================================================================
|
||||
# ✅ [核心修复] 时间格式标准化
|
||||
# 原逻辑: data_packet['target_time'] = best_date[2] (得到 "2026_02_08")
|
||||
# 新逻辑: 将 "2026_02_08" 转换为 "2026-02-08 HH:MM:SS"
|
||||
# ==============================================================================
|
||||
raw_folder_name = best_date[2] # 例如 "2026_02_08"
|
||||
formatted_date_part = raw_folder_name.replace('_', '-') # 变成 "2026-02-08"
|
||||
current_time_part = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
# 覆盖默认时间,确保数据库存入的是标准时间戳格式
|
||||
data_packet['target_time'] = f"{formatted_date_part} {current_time_part}"
|
||||
|
||||
date_path = f"{api_root}{raw_folder_name}/"
|
||||
|
||||
# --- 2. 请求具体日期的文件夹内容 (获取 numFiles) ---
|
||||
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
|
||||
folder_data = res2.json()
|
||||
|
||||
file_count = folder_data.get('numFiles', 0)
|
||||
data_packet['num_files'] = file_count
|
||||
print(f" -> {name}: 找到日期 {formatted_date_part}, 文件数: {file_count}")
|
||||
|
||||
# --- 3. 找该文件夹里最新的文件 ---
|
||||
best_file = find_closest_item(folder_data.get('items', []), False)
|
||||
|
||||
if not best_file:
|
||||
data_packet['value'] = "文件夹为空"
|
||||
results.append(data_packet)
|
||||
continue
|
||||
|
||||
file_item = best_file[1]
|
||||
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
|
||||
|
||||
# --- 4. 下载/读取内容逻辑 ---
|
||||
if is_tower_i:
|
||||
# [二进制文件] 下载逻辑
|
||||
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||
res3 = requests.get(download_url, headers=headers, timeout=20, stream=True)
|
||||
if res3.status_code == 200:
|
||||
safe_name = f"{name}_{datetime.now().strftime('%H%M%S')}.db"
|
||||
temp_path = os.path.join(get_temp_dir(), safe_name)
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(res3.content)
|
||||
|
||||
data_packet['temp_file'] = temp_path
|
||||
data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes"
|
||||
data_packet['raw_json'] = file_item # 借用 file_item 充当 raw_json
|
||||
else:
|
||||
data_packet['status'] = '异常'
|
||||
data_packet['value'] = f"下载失败: {res3.status_code}"
|
||||
else:
|
||||
# [文本文件] JSON 解析逻辑
|
||||
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
|
||||
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||
try:
|
||||
json_content = res3.json()
|
||||
data_packet['raw_json'] = json_content
|
||||
# 尝试提取 content 内容,如果没有则截取部分 JSON 字符串
|
||||
data_packet['value'] = json_content.get('content', str(json_content)[:100])
|
||||
except:
|
||||
data_packet['value'] = "JSON解析失败"
|
||||
|
||||
results.append(data_packet)
|
||||
|
||||
except Exception as e:
|
||||
data_packet['status'] = '异常'
|
||||
data_packet['value'] = str(e)[:100]
|
||||
results.append(data_packet)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"106 Crawler Error: {e}")
|
||||
|
||||
return results
|
||||
62
2_1banben/services/crawler_82.py
Normal file
62
2_1banben/services/crawler_82.py
Normal file
@ -0,0 +1,62 @@
|
||||
# services/crawler_82.py
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
from config import Config
|
||||
from datetime import datetime
|
||||
|
||||
CONFIG = Config.CRAWLER_CONFIG["82"]
|
||||
|
||||
|
||||
def run_82_logic():
|
||||
"""返回 result_list"""
|
||||
results = []
|
||||
print(">>> [82爬虫] 启动...")
|
||||
session = requests.Session()
|
||||
|
||||
try:
|
||||
session.post(f"{CONFIG['base_url']}/login.php", data=CONFIG["login"], timeout=10)
|
||||
resp = session.post(f"{CONFIG['base_url']}/GetStationList.php", timeout=10)
|
||||
html = etree.HTML(resp.content)
|
||||
if html is None: return []
|
||||
|
||||
stations = html.xpath('//option/@value')
|
||||
for sid in [s for s in stations if s]:
|
||||
data_packet = {
|
||||
'source': '82网站',
|
||||
'name': str(sid),
|
||||
'status': '正常',
|
||||
'value': '',
|
||||
'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
'raw_json': {},
|
||||
'temp_file': None
|
||||
}
|
||||
try:
|
||||
r = session.post(f"{CONFIG['base_url']}/getLastWeatherData.php", data=str(sid),
|
||||
headers={'Content-Type': 'text/plain'}, timeout=10)
|
||||
try:
|
||||
data = r.json()
|
||||
except:
|
||||
data = None
|
||||
|
||||
if data:
|
||||
d_list = data.get('date', [])
|
||||
latest = str(d_list[-1]) if d_list else "N/A"
|
||||
data_packet['target_time'] = latest
|
||||
data_packet['value'] = f"Data Points: {len(d_list)}"
|
||||
data_packet['raw_json'] = data # 🔥 存完整JSON
|
||||
else:
|
||||
data_packet['status'] = '异常'
|
||||
data_packet['value'] = "返回空数据"
|
||||
|
||||
except Exception as e:
|
||||
data_packet['status'] = '异常'
|
||||
data_packet['value'] = "单个采集失败"
|
||||
|
||||
results.append(data_packet)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"82 Crawler Error: {e}")
|
||||
|
||||
return results
|
||||
260
2_1banben/services/iot_api.py
Normal file
260
2_1banben/services/iot_api.py
Normal file
@ -0,0 +1,260 @@
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from flask import current_app
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 1. 配置获取 (从 Flask 全局配置读取)
|
||||
# ==========================================
|
||||
def get_config(key):
|
||||
"""
|
||||
优先从 Flask 应用上下文获取配置
|
||||
"""
|
||||
try:
|
||||
if current_app:
|
||||
return current_app.config.get(key)
|
||||
except RuntimeError:
|
||||
# 如果在非 Flask 上下文运行(如单独调试),返回 None 或报错
|
||||
print("[Warning] Not in Flask context")
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 2. 核心签名算法 (Java 兼容版)
|
||||
# ==========================================
|
||||
def generate_signature_final(params, is_json_body=False):
|
||||
"""
|
||||
签名公式: secret + appid + timestamp + paramData + secret -> MD5(lower)
|
||||
"""
|
||||
appid = get_config('IOT_APP_ID')
|
||||
secret = get_config('IOT_SECRET')
|
||||
|
||||
# 1. 拷贝参数,避免修改原字典
|
||||
params_copy = params.copy()
|
||||
|
||||
# 2. 移除不参与签名的字段 (timestamp, appid, signature)
|
||||
# 注意:timestamp 在签名公式中是单独拼接的,不在 paramData 里
|
||||
timestamp = str(params_copy.pop('timestamp', int(time.time() * 1000)))
|
||||
if 'appid' in params_copy: params_copy.pop('appid')
|
||||
if 'signature' in params_copy: params_copy.pop('signature')
|
||||
|
||||
# 3. 生成 paramData
|
||||
param_data = ""
|
||||
if is_json_body:
|
||||
# POST JSON 模式: 无空格 JSON 字符串,按 key 排序
|
||||
# separators=(',', ':') 去除默认的空格
|
||||
param_data = json.dumps(params_copy, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
|
||||
else:
|
||||
# GET 键值对模式: key=value 直接拼接 (注意:Java版没有 '&' 符号)
|
||||
sorted_keys = sorted([k for k in params_copy.keys() if params_copy[k] is not None])
|
||||
kv_list = [f"{k}={params_copy[k]}" for k in sorted_keys]
|
||||
param_data = "".join(kv_list)
|
||||
|
||||
# 4. 拼接最终字符串
|
||||
sign_str = f"{secret}{appid}{timestamp}{param_data}{secret}"
|
||||
|
||||
# 5. MD5 加密并转小写
|
||||
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().lower()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 3. 业务接口封装
|
||||
# ==========================================
|
||||
|
||||
def get_access_token():
|
||||
"""
|
||||
登录获取 Token
|
||||
"""
|
||||
base_url = get_config('IOT_BASE_URL')
|
||||
login_url = get_config('IOT_URL_LOGIN')
|
||||
|
||||
if not base_url or not login_url:
|
||||
print("[IoT API] 配置缺失")
|
||||
return None
|
||||
|
||||
url = base_url + login_url
|
||||
payload = {
|
||||
"username": get_config('IOT_USERNAME'),
|
||||
"password": get_config('IOT_PASSWORD')
|
||||
}
|
||||
|
||||
try:
|
||||
# print(f"DEBUG: 正在登录 IoT 平台...")
|
||||
res = requests.post(url, json=payload, timeout=10).json()
|
||||
if res.get('code') == 0:
|
||||
token = res['data']['accessToken']
|
||||
return token
|
||||
else:
|
||||
print(f"[IoT API] 登录失败: {res.get('msg')}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[IoT API] 登录异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_iot_card_page(token, page_no=1, page_size=100):
|
||||
"""
|
||||
获取单页卡列表
|
||||
"""
|
||||
base_url = get_config('IOT_BASE_URL')
|
||||
page_url = get_config('IOT_URL_PAGE')
|
||||
url = base_url + page_url
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
params = {
|
||||
"appid": get_config('IOT_APP_ID'),
|
||||
"pageNo": page_no,
|
||||
"pageSize": page_size,
|
||||
"timestamp": timestamp
|
||||
}
|
||||
|
||||
# 计算签名
|
||||
sign = generate_signature_final(params, is_json_body=False)
|
||||
params['signature'] = sign
|
||||
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=15)
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"[IoT API] 获取列表页失败 (Page {page_no}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_iot_card_details_batch(token, iccids):
|
||||
"""
|
||||
批量获取卡详情
|
||||
"""
|
||||
if not iccids: return None
|
||||
|
||||
base_url = get_config('IOT_BASE_URL')
|
||||
detail_url = get_config('IOT_URL_DETAIL')
|
||||
url = base_url + detail_url
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
|
||||
payload = {
|
||||
"iccids": iccids,
|
||||
"timestamp": timestamp
|
||||
}
|
||||
|
||||
# 计算签名 (POST JSON)
|
||||
sign = generate_signature_final(payload, is_json_body=True)
|
||||
payload['signature'] = sign
|
||||
# 补回 timestamp 到 body 中,因为签名计算时 pop 掉了
|
||||
payload['timestamp'] = timestamp
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=20)
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"[IoT API] 获取详情失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 4. 主服务入口 (供 api.py 调用)
|
||||
# ==========================================
|
||||
|
||||
def sync_iot_data_service():
|
||||
"""
|
||||
执行完整的同步流程:
|
||||
1. 登录
|
||||
2. 遍历所有分页获取 ICCID
|
||||
3. 批量查询详情
|
||||
4. 解析 cardStatus 状态码
|
||||
5. 返回完整数据列表 (List[Dict])
|
||||
"""
|
||||
print("[IoT Service] 开始同步任务...")
|
||||
|
||||
# ✅ 1. 定义状态码映射表 (根据提供的需求文档)
|
||||
STATUS_MAP = {
|
||||
"1": "测试期",
|
||||
"2": "沉默期",
|
||||
"3": "在使用",
|
||||
"4": "停机",
|
||||
"5": "停机保号",
|
||||
"6": "销户"
|
||||
}
|
||||
|
||||
token = get_access_token()
|
||||
if not token:
|
||||
return []
|
||||
|
||||
all_iccids = []
|
||||
page_no = 1
|
||||
page_size = 100
|
||||
|
||||
# ✅ 2. 循环翻页获取所有 ICCID
|
||||
while True:
|
||||
res = get_iot_card_page(token, page_no, page_size)
|
||||
|
||||
if not res or (res.get('code') != 0 and res.get('code') != 200):
|
||||
print(f"[IoT Service] 列表获取结束或中断: {res.get('msg') if res else 'No Response'}")
|
||||
break
|
||||
|
||||
data_field = res.get('data', {})
|
||||
rows = []
|
||||
if isinstance(data_field, list):
|
||||
rows = data_field
|
||||
elif isinstance(data_field, dict):
|
||||
rows = data_field.get('rows', []) or data_field.get('list', [])
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
current_batch = [str(x.get('iccid')) for x in rows if x.get('iccid')]
|
||||
all_iccids.extend(current_batch)
|
||||
|
||||
if len(rows) < page_size:
|
||||
break
|
||||
|
||||
page_no += 1
|
||||
time.sleep(0.2)
|
||||
|
||||
total_count = len(all_iccids)
|
||||
if total_count == 0:
|
||||
print("[IoT Service] 未找到任何卡片")
|
||||
return []
|
||||
|
||||
# ✅ 3. 分批查询详情并处理状态
|
||||
final_data_list = []
|
||||
batch_size = 50
|
||||
|
||||
for i in range(0, total_count, batch_size):
|
||||
batch_iccids = all_iccids[i: i + batch_size]
|
||||
detail_res = get_iot_card_details_batch(token, batch_iccids)
|
||||
|
||||
if detail_res and (detail_res.get('code') == 0 or detail_res.get('code') == 200):
|
||||
details = detail_res.get('data', [])
|
||||
if isinstance(details, list):
|
||||
|
||||
# === 核心修改:增加状态解析逻辑 ===
|
||||
for card in details:
|
||||
# 获取原始状态码 (如 "3")
|
||||
raw_status = str(card.get('cardStatus', ''))
|
||||
|
||||
# 匹配中文描述 (如 "在使用")
|
||||
status_desc = STATUS_MAP.get(raw_status, "未知状态")
|
||||
|
||||
# 将描述写入新字段,前端可直接取用 card.statusDesc
|
||||
card['statusDesc'] = status_desc
|
||||
|
||||
final_data_list.append(card)
|
||||
# =================================
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
print(f"[IoT Service] 同步完成,共获取 {len(final_data_list)} 条详情数据")
|
||||
return final_data_list
|
||||
8
zhandianxinxi/.idea/.gitignore
generated
vendored
Normal file
8
zhandianxinxi/.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
zhandianxinxi/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
zhandianxinxi/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
</profile>
|
||||
</component>
|
||||
9
zhandianxinxi/.idea/misc.xml
generated
Normal file
9
zhandianxinxi/.idea/misc.xml
generated
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12 (zhandianxinxi)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
zhandianxinxi/.idea/modules.xml
generated
Normal file
8
zhandianxinxi/.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/zhandianxinxi.iml" filepath="$PROJECT_DIR$/zhandianxinxi.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
zhandianxinxi/.idea/vcs.xml
generated
Normal file
6
zhandianxinxi/.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
zhandianxinxi/zhandianxinxi.iml
Normal file
9
zhandianxinxi/zhandianxinxi.iml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (zhandianxinxi)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
24
zhandianxinxi/光谱数据监控/.gitignore
vendored
Normal file
24
zhandianxinxi/光谱数据监控/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
web_dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
zhandianxinxi/光谱数据监控/.vscode/extensions.json
vendored
Normal file
3
zhandianxinxi/光谱数据监控/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
zhandianxinxi/光谱数据监控/README.md
Normal file
5
zhandianxinxi/光谱数据监控/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
zhandianxinxi/光谱数据监控/index.html
Normal file
13
zhandianxinxi/光谱数据监控/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>my-vue-app</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2113
zhandianxinxi/光谱数据监控/package-lock.json
generated
Normal file
2113
zhandianxinxi/光谱数据监控/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
zhandianxinxi/光谱数据监控/package.json
Normal file
23
zhandianxinxi/光谱数据监控/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "my-vue-app",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"axios": "^1.5.1",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.3.14",
|
||||
"vue": "^3.3.4",
|
||||
"vue-json-viewer": "^3.0.4",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "4.5.0",
|
||||
"vite": "4.5.0"
|
||||
}
|
||||
}
|
||||
1
zhandianxinxi/光谱数据监控/public/vite.svg
Normal file
1
zhandianxinxi/光谱数据监控/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
59
zhandianxinxi/光谱数据监控/src/App.vue
Normal file
59
zhandianxinxi/光谱数据监控/src/App.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<main class="main-content">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
|
||||
<footer class="version-footer">
|
||||
2.5版本(加入每日数据个数) © 2026 Device Monitor
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// App.vue 保持简洁
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* --- 全局样式 --- */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f7fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
overflow-x: hidden; /* 防止 iOS 橡皮筋效果 */
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
max-width: 100vw; /* 强制不超过屏幕宽 */
|
||||
}
|
||||
|
||||
/* ✅ 关键:内容区滚动控制 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto; /* 允许横向滚动 */
|
||||
-webkit-overflow-scrolling: touch; /* 移动端顺滑滚动 */
|
||||
}
|
||||
|
||||
.version-footer {
|
||||
text-align: center;
|
||||
padding: 15px 0;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
background-color: #f5f7fa;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
}
|
||||
</style>
|
||||
1
zhandianxinxi/光谱数据监控/src/assets/vue.svg
Normal file
1
zhandianxinxi/光谱数据监控/src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
30
zhandianxinxi/光谱数据监控/src/main.js
Normal file
30
zhandianxinxi/光谱数据监控/src/main.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue' // 引入根组件
|
||||
import router from './router' // 引入路由配置
|
||||
|
||||
// 引入 Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
// 引入 JSON 查看器 (用于 DataMonitor 中查看原始数据)
|
||||
import JsonViewer from 'vue-json-viewer'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 1. 挂载路由
|
||||
app.use(router)
|
||||
|
||||
// 2. 挂载 Element Plus
|
||||
app.use(ElementPlus)
|
||||
|
||||
// 3. 注册所有图标 (方便在各个组件直接使用 <Edit /> 等)
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 4. 挂载 JSON Viewer
|
||||
app.use(JsonViewer)
|
||||
|
||||
// 5. 挂载到 DOM
|
||||
app.mount('#app')
|
||||
73
zhandianxinxi/光谱数据监控/src/router/index.js
Normal file
73
zhandianxinxi/光谱数据监控/src/router/index.js
Normal file
@ -0,0 +1,73 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 1. 引入登录页面(建议新建 views/Login.vue)
|
||||
import Login from '../views/Login.vue'
|
||||
// 2. 首页组件
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { title: '系统登录' }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: { title: '设备监控总览', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/data-monitor',
|
||||
name: 'CrawledData',
|
||||
// 路由懒加载
|
||||
component: () => import('../views/DataMonitor.vue'),
|
||||
meta: { title: '数据爬取监控', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'MaintenanceLogs',
|
||||
component: () => import('../views/MaintenanceLogs.vue'),
|
||||
meta: { title: '维修日志中心', requiresAuth: true }
|
||||
},
|
||||
// 捕获所有未定义的路径,跳转回登录页或首页
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// =======================
|
||||
// 核心:全局路由守卫
|
||||
// =======================
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 1. 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
// 2. 检查登录状态 (从本地存储获取)
|
||||
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true'
|
||||
|
||||
// 3. 鉴权逻辑
|
||||
if (to.meta.requiresAuth && !isLoggedIn) {
|
||||
// 如果页面需要登录但用户未登录,强行拦截并跳转到登录页
|
||||
ElMessage.error('请先登录系统')
|
||||
next({ name: 'Login' })
|
||||
} else if (to.name === 'Login' && isLoggedIn) {
|
||||
// 如果用户已登录却尝试访问登录页,直接送他去首页
|
||||
next({ name: 'Dashboard' })
|
||||
} else {
|
||||
// 否则正常放行
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
79
zhandianxinxi/光谱数据监控/src/style.css
Normal file
79
zhandianxinxi/光谱数据监控/src/style.css
Normal file
@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
638
zhandianxinxi/光谱数据监控/src/views/Dashboard.vue
Normal file
638
zhandianxinxi/光谱数据监控/src/views/Dashboard.vue
Normal file
@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<el-card shadow="never" class="main-card">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<div class="left-panel">
|
||||
<h2 class="sys-title">📡 光谱设备监控</h2>
|
||||
<div class="sys-status">
|
||||
<el-tag type="info" effect="plain" round size="small">
|
||||
<el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" plain icon="Plus" @click="showAddDialog = true">新增</el-button>
|
||||
<el-button type="primary" plain icon="Link" @click="openIoTBinder">卡绑定</el-button>
|
||||
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">日志</el-button>
|
||||
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">检测</el-button>
|
||||
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
|
||||
<div class="divider-mobile"></div>
|
||||
<el-button type="danger" plain icon="SwitchButton" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="status-summary">
|
||||
<el-tag color="#409EFF" effect="dark" class="legend-tag">修</el-tag>
|
||||
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线 / 严重滞后</el-tag>
|
||||
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后 / 流量超标</el-tag>
|
||||
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">数据异常 / 昨日</el-tag>
|
||||
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filter-section">
|
||||
<el-radio-group v-model="filters.status" size="default">
|
||||
<el-radio-button label="all">全部({{ summary.totalCount }})</el-radio-button>
|
||||
<el-radio-button label="abnormal" class="red-radio">
|
||||
状态异常({{ summary.errorCount + summary.warningCount }})
|
||||
</el-radio-button>
|
||||
<el-radio-button label="data_error" class="yellow-radio">
|
||||
数据异常({{ summary.dataErrorCount }})
|
||||
</el-radio-button>
|
||||
<el-radio-button label="hidden" class="gray-radio">
|
||||
回收({{ summary.hiddenCount }})
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索设备名称..."
|
||||
class="search-input"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div class="total-usage-tag">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<span class="label">卡池总用量:</span>
|
||||
<span class="value">{{ totalUsageSum.toFixed(2) }} M</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="filteredData"
|
||||
border
|
||||
v-loading="loading"
|
||||
style="width: 100%; min-width: 1250px;"
|
||||
:row-class-name="tableRowClassName"
|
||||
:height="tableHeight"
|
||||
:default-sort="{ prop: 'sortWeight', order: 'descending' }"
|
||||
>
|
||||
<el-table-column label="状态" width="100" align="center" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_hidden" color="#909399" effect="dark" style="border:none; color:#fff;">隐藏</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
:color="row.statusColor"
|
||||
effect="dark"
|
||||
style="border:none; width: 80px;"
|
||||
:style="{ color: row.statusLabelColor || '#fff' }"
|
||||
>
|
||||
{{ row.statusLabel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="设备名称" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
class="device-name-wrapper"
|
||||
:class="{ 'clickable-row': !row.is_hidden }"
|
||||
@click="handleDeviceClick(row)"
|
||||
>
|
||||
<span class="device-name" :class="{ 'text-deleted': row.is_hidden }">
|
||||
{{ formatDisplayName(row.name) }}
|
||||
</span>
|
||||
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
|
||||
<el-tag v-if="row.isBound" size="small" type="info" effect="plain" style="margin-left:5px; height: 18px; line-height: 16px; padding:0 4px;">卡</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="今日文件" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip content="点击查看历史文件趋势" placement="top">
|
||||
<div
|
||||
class="file-count-cell"
|
||||
@click="openHistoryDialog(row)"
|
||||
:class="{ 'has-data': row.file_count > 0 }"
|
||||
>
|
||||
<el-tag v-if="row.file_count > 0" type="primary" effect="plain" round size="small">
|
||||
{{ row.file_count }} 个
|
||||
</el-tag>
|
||||
<span v-else style="color: #ccc; font-size: 12px;">--</span>
|
||||
|
||||
<el-icon v-if="!row.is_hidden" class="history-icon"><Histogram /></el-icon>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="安装地点" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isEditingSite" class="editing-cell">
|
||||
<el-input
|
||||
v-model="row.tempSite"
|
||||
size="small"
|
||||
@blur="saveSite(row)"
|
||||
@keyup.enter="saveSite(row)"
|
||||
class="site-input-inner"
|
||||
placeholder="输入后回车"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="display-cell" @click="handleEditSite(row)">
|
||||
<span>{{ row.install_site || '点击填写' }}</span>
|
||||
<el-icon class="edit-icon"><EditPen /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="本月流量" width="130" prop="trafficNum" sortable>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isBound">
|
||||
<span :style="{ fontWeight: '600', color: row.trafficWarning ? '#E6A23C' : '#606266' }">
|
||||
{{ row.trafficNum }} M
|
||||
</span>
|
||||
<el-tooltip v-if="row.trafficWarning" content="流量超标 (>=500M)" placement="top">
|
||||
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else style="color: #ccc;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="卡状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isBound">
|
||||
<el-tag
|
||||
:type="getCardStatusType(row.statusDesc)"
|
||||
effect="light"
|
||||
size="small"
|
||||
style="font-weight: bold;"
|
||||
>
|
||||
{{ row.statusDesc || '未知' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else style="color: #ccc;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="服务截止" width="140">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isBound && row.stopDate">
|
||||
<span :style="{ color: row.expireWarning ? '#E6A23C' : '#606266', fontWeight: row.expireWarning ? 'bold' : 'normal' }">
|
||||
{{ row.stopDate }}
|
||||
</span>
|
||||
<el-tooltip v-if="row.expireWarning" content="即将过期 (<30天)" placement="top">
|
||||
<el-icon color="#E6A23C" style="margin-left: 4px; cursor: help;"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else style="color: #ccc;">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="数据时效与质量" width="260" prop="sortWeight" sortable>
|
||||
<template #default="{ row }">
|
||||
<div style="font-size: 13px; display:flex; align-items:center; gap:5px; color: #606266; margin-bottom: 4px;">
|
||||
<el-icon><Clock /></el-icon> {{ row.latest_time || '尚未同步' }}
|
||||
</div>
|
||||
|
||||
<div v-if="!row.is_maintaining && !row.is_hidden">
|
||||
<div v-if="row.statusType === 'error'" class="status-text error-text">
|
||||
⚠️ {{ row.statusReason }}
|
||||
</div>
|
||||
<div v-else-if="row.statusType === 'warning'" class="status-text warning-text">
|
||||
⚠️ {{ row.statusReason }}
|
||||
</div>
|
||||
<div v-else-if="row.statusType === 'slight-warning'" class="status-text slight-warning-text">
|
||||
⚠️ {{ row.statusReason }}
|
||||
</div>
|
||||
<div v-else class="status-text success-text">
|
||||
✅ 状态正常
|
||||
</div>
|
||||
|
||||
<div v-if="row.statusType !== 'error'" style="margin-top: 4px;">
|
||||
<el-tag v-if="row.data_quality === 'error'" type="danger" size="small" effect="dark">
|
||||
<el-icon><Warning /></el-icon> 数据严重异常
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.data_quality === 'warning'" type="warning" size="small" effect="dark">
|
||||
<el-icon><WarningFilled /></el-icon> 数值警告
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.statusType !== 'warning' && row.statusType !== 'slight-warning'" type="success" size="small" effect="plain">
|
||||
数值正常
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">🛠️ 维护中</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-group">
|
||||
<template v-if="row.is_hidden">
|
||||
<el-button type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-switch
|
||||
v-model="row.is_maintaining"
|
||||
inline-prompt
|
||||
active-text="修"
|
||||
inactive-text="行"
|
||||
style="--el-switch-on-color: #409EFF;"
|
||||
:before-change="() => handleMaintenanceBeforeChange(row)"
|
||||
/>
|
||||
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
|
||||
<el-popconfirm title="确定隐藏?" @confirm="toggleHidden(row, true)">
|
||||
<template #reference>
|
||||
<el-button type="danger" link icon="Delete">隐藏</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<DataMonitor ref="dataMonitorRef" />
|
||||
<MaintenanceLogs ref="maintenanceLogsRef" />
|
||||
<IoTDeviceBinder ref="iotBinderRef" @update-success="fetchData" />
|
||||
|
||||
<FileHistoryDialog ref="fileHistoryRef" />
|
||||
|
||||
<el-dialog v-model="showAddDialog" title="手动添加设备" width="400px" align-center>
|
||||
<el-form :model="newDeviceForm" label-width="80px">
|
||||
<el-form-item label="设备名称">
|
||||
<el-input v-model="newDeviceForm.name" placeholder="请输入唯一设备名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="安装地点">
|
||||
<el-input v-model="newDeviceForm.site" placeholder="可选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isAdding" @click="handleAddDeviceSubmit">
|
||||
确认添加
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue'
|
||||
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, Warning, WarningFilled, Plus, Odometer, Link, Histogram } from '@element-plus/icons-vue'
|
||||
|
||||
import DataMonitor from './DataMonitor.vue'
|
||||
import MaintenanceLogs from './MaintenanceLogs.vue'
|
||||
import IoTDeviceBinder from './IoTDeviceBinder.vue'
|
||||
// ✅ 引入新组件
|
||||
import FileHistoryDialog from './FileHistoryDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const runningTask = ref(false)
|
||||
const rawData = ref([])
|
||||
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 iotBinderRef = ref(null)
|
||||
// ✅ 定义新的 ref
|
||||
const fileHistoryRef = ref(null)
|
||||
|
||||
const showAddDialog = ref(false)
|
||||
const isAdding = ref(false)
|
||||
const newDeviceForm = reactive({ name: '', site: '' })
|
||||
|
||||
// === 辅助函数:根据中文状态返回 Tag 颜色 ===
|
||||
const getCardStatusType = (status) => {
|
||||
if (status === '在使用') return 'success' // 绿色
|
||||
if (status === '停机' || status === '销户') return 'danger' // 红色
|
||||
if (status === '停机保号' || status === '沉默期') return 'warning' // 黄色
|
||||
if (status === '测试期') return 'info' // 灰色
|
||||
return 'info' // 默认
|
||||
}
|
||||
|
||||
// === 核心数据处理逻辑 ===
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/api/devices_overview`)
|
||||
const backendList = res.data.data || res.data
|
||||
const now = new Date()
|
||||
|
||||
rawData.value = backendList.map(item => {
|
||||
const isHidden = item.is_hidden === true || item.is_hidden === 1
|
||||
const isBound = !!item.isBound
|
||||
const isOrphanIoT = (item.source === 'iot_card')
|
||||
const isWhitelist = !!item.is_whitelist
|
||||
|
||||
// === 1. 智能时间解析与格式化 (增强版) ===
|
||||
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
|
||||
let timeStr = item.latest_time
|
||||
|
||||
// 默认显示原始值,稍后如果解析成功则覆盖它
|
||||
let displayTime = timeStr
|
||||
|
||||
if (timeStr && timeStr !== 'N/A') {
|
||||
let d = null;
|
||||
const str = timeStr.toString().trim();
|
||||
|
||||
// A. 尝试匹配标准格式: YYYY-MM-DD HH:mm:ss
|
||||
const matchStandard = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
|
||||
|
||||
if (matchStandard) {
|
||||
d = new Date(
|
||||
parseInt(matchStandard[1]),
|
||||
parseInt(matchStandard[2]) - 1,
|
||||
parseInt(matchStandard[3]),
|
||||
parseInt(matchStandard[4]),
|
||||
parseInt(matchStandard[5]),
|
||||
parseInt(matchStandard[6])
|
||||
);
|
||||
} else {
|
||||
// B. 兜底逻辑:处理下划线或其他格式 (如 2026_01_14)
|
||||
// 先把下划线全换成横杠
|
||||
let cleanStr = str.replace(/_/g, '-')
|
||||
// 如果长度不够(只有日期),补全时间,防止 new Date 解析成 UTC 0点导致时差
|
||||
if (cleanStr.length <= 10) {
|
||||
cleanStr += ' 00:00:00'
|
||||
}
|
||||
// 处理 T 分隔符 (ISO格式)
|
||||
cleanStr = cleanStr.replace(' ', 'T')
|
||||
d = new Date(cleanStr);
|
||||
}
|
||||
|
||||
// C. 如果解析成功,强制重新生成统一的显示字符串
|
||||
if (d && !isNaN(d.getTime())) {
|
||||
validTime = true
|
||||
isToday = d.toDateString() === now.toDateString()
|
||||
|
||||
const diff = now - d
|
||||
diffHours = (diff > 0 ? diff : 0) / (1000 * 3600)
|
||||
diffDays = diffHours / 24
|
||||
|
||||
// 🌟 核心修改点:生成标准显示格式 YYYY-MM-DD HH:mm:ss 🌟
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
// 这行代码保证了无论后端发什么,前端都显示得很漂亮
|
||||
displayTime = `${y}-${m}-${dd} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 解析监测数值 (保留旧逻辑)
|
||||
let currentValueNum = 0
|
||||
if (item.current_value) {
|
||||
const match = String(item.current_value).match(/(\d+(\.\d+)?)/)
|
||||
if (match) currentValueNum = parseFloat(match[0])
|
||||
}
|
||||
|
||||
// 3. 流量与过期计算
|
||||
let trafficNum = 0
|
||||
let rawTraffic = item.usedTraffic
|
||||
if ((rawTraffic === undefined || rawTraffic === null) && item.json_data) {
|
||||
try { const j = JSON.parse(item.json_data); rawTraffic = j.usedTraffic } catch(e) {}
|
||||
}
|
||||
if (rawTraffic) {
|
||||
trafficNum = parseFloat(rawTraffic)
|
||||
if (isNaN(trafficNum)) trafficNum = 0
|
||||
}
|
||||
|
||||
// === 修改处:恢复流量超标警告判断,用于标黄 ===
|
||||
const trafficWarning = (trafficNum >= 500 && !isWhitelist)
|
||||
|
||||
let expireWarning = false
|
||||
if (item.stopDate && item.stopDate !== 'N/A') {
|
||||
const stopD = new Date(item.stopDate.replace(/_/g, '-'))
|
||||
if (!isNaN(stopD.getTime())) {
|
||||
const daysLeft = (stopD - now) / (1000 * 3600 * 24)
|
||||
if (daysLeft < 30) expireWarning = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 状态判定
|
||||
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
|
||||
let statusReason = ''
|
||||
let sortWeight = diffHours
|
||||
|
||||
if (item.is_maintaining) {
|
||||
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
|
||||
sortWeight = Number.MAX_SAFE_INTEGER;
|
||||
} else if (!validTime || item.status === 'offline') {
|
||||
statusLabel = '离线'; statusColor = '#F56C6C'; statusType = 'error';
|
||||
statusReason = validTime ? '设备离线' : '暂无数据';
|
||||
sortWeight = 80000000;
|
||||
} else if (diffDays > 7) {
|
||||
statusLabel = '严重滞后'; statusColor = '#F56C6C'; statusType = 'error';
|
||||
statusReason = `滞后 ${Math.floor(diffDays)} 天`;
|
||||
} else if (diffHours > 24) {
|
||||
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
|
||||
statusReason = `滞后 ${Math.floor(diffDays)} 天`;
|
||||
} else if (expireWarning) {
|
||||
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
|
||||
statusReason = `即将过期`;
|
||||
sortWeight = 400;
|
||||
} else if (!isToday) {
|
||||
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
|
||||
statusReason = '非今日数据';
|
||||
} else {
|
||||
sortWeight = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
latest_time: displayTime,
|
||||
is_hidden: isHidden,
|
||||
isOrphanIoT,
|
||||
isBound,
|
||||
isWhitelist,
|
||||
diffDays, diffHours, sortWeight, isToday,
|
||||
statusColor, statusLabel, statusType, statusLabelColor, statusReason,
|
||||
isEditingSite: false, tempSite: '',
|
||||
data_quality: item.data_quality || 'ok',
|
||||
currentValueNum,
|
||||
trafficNum,
|
||||
trafficWarning,
|
||||
expireWarning,
|
||||
file_count: item.file_count || 0 // ✅ 绑定后端返回的文件数字段
|
||||
}
|
||||
}).sort((a, b) => b.sortWeight - a.sortWeight)
|
||||
|
||||
lastCheckTime.value = new Date().toLocaleString()
|
||||
} catch (e) {
|
||||
ElMessage.error('获取数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === 筛选逻辑 ===
|
||||
const summary = computed(() => {
|
||||
const active = rawData.value.filter(r => !r.is_hidden && !r.isOrphanIoT)
|
||||
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 filteredData = computed(() => {
|
||||
return rawData.value.filter(item => {
|
||||
// 隐藏孤儿卡
|
||||
if (item.isOrphanIoT) return false
|
||||
|
||||
if (filters.status === 'hidden') return item.is_hidden
|
||||
if (item.is_hidden) return false
|
||||
|
||||
if (filters.status === 'abnormal') return ['error', 'warning', 'slight-warning'].includes(item.statusType)
|
||||
if (filters.status === 'data_error') return ['error', 'warning'].includes(item.data_quality)
|
||||
return true
|
||||
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
|
||||
})
|
||||
|
||||
// === 卡池总用量 ===
|
||||
const totalUsageSum = computed(() => {
|
||||
return rawData.value.reduce((sum, item) => {
|
||||
if (item.source === 'iot_card') {
|
||||
return sum + (item.trafficNum || 0)
|
||||
}
|
||||
return sum
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// === 交互函数 ===
|
||||
// ✅ 新增:打开历史记录弹窗
|
||||
const openHistoryDialog = (row) => {
|
||||
if (row.is_hidden) return
|
||||
if (fileHistoryRef.value) {
|
||||
fileHistoryRef.value.open(row)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDeviceSubmit = async () => { if (!newDeviceForm.name) return; await axios.post(`${API_BASE}/api/add_device`, newDeviceForm); showAddDialog.value=false; fetchData() }
|
||||
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 openIoTBinder = () => { if (iotBinderRef.value) iotBinderRef.value.open() }
|
||||
const runManualMonitor = async () => { runningTask.value=true; await axios.post(`${API_BASE}/api/run_monitor`); setTimeout(()=>fetchData(), 3000); setTimeout(()=>runningTask.value=false, 1000) }
|
||||
const handleEditSite = (row) => { row.tempSite = row.install_site; row.isEditingSite = true; nextTick(() => document.querySelector('.site-input-inner input')?.focus()) }
|
||||
const saveSite = async (row) => { if(!row.isEditingSite)return; row.isEditingSite=false; await axios.post(`${API_BASE}/api/update_site`, {name:row.name, site:row.tempSite}); row.install_site=row.tempSite }
|
||||
const handleMaintenanceBeforeChange = (row) => { return new Promise(r => { axios.post(`${API_BASE}/api/toggle_maintenance`, {name:row.name, is_maintaining:!row.is_maintaining}).then(() => {row.is_maintaining=!row.is_maintaining; fetchData(); r(true)}).catch(()=>r(false)) }) }
|
||||
const toggleHidden = async (row, val) => { await axios.post(`${API_BASE}/api/toggle_hidden`, {name:row.name, is_hidden:val}); row.is_hidden=val; fetchData() }
|
||||
const handleLogout = () => { localStorage.removeItem('token'); router.push('/') }
|
||||
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||
|
||||
// 行高亮逻辑 (融合了数据异常的高亮)
|
||||
const tableRowClassName = ({ row }) => {
|
||||
if (row.is_hidden) return 'hidden-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) })
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
|
||||
.main-card { border-radius: 8px; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.sys-title { font-size: 20px; font-weight: 700; color: #303133; margin: 0; }
|
||||
.left-panel { display: flex; align-items: center; gap: 10px; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.legend-tag { font-weight: bold; border: none; font-size: 12px; }
|
||||
.toolbar { background: #fff; padding: 10px; border-radius: 6px; margin-bottom: 10px; border: 1px solid #e4e7ed; }
|
||||
.filter-section { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.search-input { width: 220px; }
|
||||
:deep(.red-radio.is-active .el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; box-shadow: -1px 0 0 0 #F56C6C; }
|
||||
:deep(.yellow-radio.is-active .el-radio-button__inner) { background-color: #E6A23C; border-color: #E6A23C; box-shadow: -1px 0 0 0 #E6A23C; }
|
||||
:deep(.gray-radio.is-active .el-radio-button__inner) { background-color: #909399; border-color: #909399; box-shadow: -1px 0 0 0 #909399; }
|
||||
:deep(.red-radio .el-radio-button__inner), :deep(.yellow-radio .el-radio-button__inner), :deep(.gray-radio .el-radio-button__inner) { color: #606266; }
|
||||
.total-usage-tag { display: flex; align-items: center; gap: 5px; background: #f0f9ff; border: 1px solid #cce9ff; padding: 5px 12px; border-radius: 4px; color: #409EFF; margin-left: 5px; }
|
||||
.total-usage-tag .label { font-size: 13px; font-weight: bold; }
|
||||
.total-usage-tag .value { font-size: 14px; font-weight: 800; }
|
||||
.device-name-wrapper { display: flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||
.device-name { font-weight: bold; font-size: 14px; color: #303133; }
|
||||
.device-name-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
|
||||
.text-deleted { text-decoration: line-through; color: #999; }
|
||||
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
|
||||
.error-text { color: #F56C6C; }
|
||||
.warning-text { color: #E6A23C; }
|
||||
.success-text { color: #67C23A; }
|
||||
.slight-warning-text { color: #E6A23C; }
|
||||
.maintenance-text { color: #409EFF; }
|
||||
.display-cell { cursor: pointer; padding: 4px 0; display: flex; justify-content: space-between; }
|
||||
.edit-icon { color: #409EFF; }
|
||||
:deep(.error-row) { background-color: #fef0f0 !important; }
|
||||
:deep(.warning-row) { background-color: #fdf6ec !important; }
|
||||
:deep(.maintenance-row) { background-color: #f0f9ff !important; }
|
||||
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
|
||||
/* 增加原有代码的数据异常背景色 */
|
||||
:deep(.data-error-row) { background-color: #ffe6e6 !important; }
|
||||
:deep(.data-warning-row) { background-color: #fffbe6 !important; }
|
||||
|
||||
/* ✅ 新增样式 */
|
||||
.file-count-cell {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
transition: all 0.2s;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.file-count-cell:hover {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
.file-count-cell:hover .el-tag {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.history-icon {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.file-count-cell:hover .history-icon {
|
||||
opacity: 1;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.dashboard-container { padding: 5px; }
|
||||
.left-panel, .header-actions { width: 100%; justify-content: space-between; margin-bottom: 5px; }
|
||||
.header-actions .el-button { flex: 1; margin: 0 2px; }
|
||||
.filter-section { justify-content: space-between; }
|
||||
.el-radio-group { width: 100%; display: flex; }
|
||||
.el-radio-button { flex: 1; }
|
||||
.search-input { width: 100%; margin-top: 5px; }
|
||||
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
|
||||
}
|
||||
</style>
|
||||
355
zhandianxinxi/光谱数据监控/src/views/DataMonitor.vue
Normal file
355
zhandianxinxi/光谱数据监控/src/views/DataMonitor.vue
Normal file
@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="📈 设备数据详情"
|
||||
width="90%"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
@closed="disposeCharts"
|
||||
append-to-body
|
||||
>
|
||||
<div class="dialog-header-bar">
|
||||
<div class="device-info">
|
||||
<span class="d-name">{{ formatDisplayName(deviceName) }}</span>
|
||||
<el-tag size="small" type="info" v-if="currentSource">Source: {{ currentSource }}</el-tag>
|
||||
<span class="latest-time-hint" v-if="dataTimestamp">
|
||||
(数据时间: {{ dataTimestamp }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="date-filter">
|
||||
<span class="label">选择日期:</span>
|
||||
<el-date-picker
|
||||
v-model="selectedDate"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
:disabled-date="disabledDate"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="handleDateChange"
|
||||
:clearable="false"
|
||||
style="width: 150px;"
|
||||
/>
|
||||
<el-button
|
||||
circle
|
||||
icon="Refresh"
|
||||
type="primary"
|
||||
plain
|
||||
@click="loadData"
|
||||
style="margin-left: 10px"
|
||||
title="刷新当前数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="monitor-dialog-content" v-loading="loading">
|
||||
<el-empty
|
||||
v-if="!loading && chartModules.length === 0"
|
||||
:description="emptyText"
|
||||
/>
|
||||
|
||||
<div v-else class="charts-scroll-container">
|
||||
<div
|
||||
v-for="(module, index) in chartModules"
|
||||
:key="index"
|
||||
class="chart-wrapper"
|
||||
>
|
||||
<div :ref="(el) => setChartRef(el, index)" class="echart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, onBeforeUnmount } from 'vue'
|
||||
import axios from 'axios'
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn' // 引入中文语言包
|
||||
|
||||
// --- 状态定义 ---
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const deviceName = ref('')
|
||||
const currentSource = ref('') // 核心:保存设备源类型 (106 或 82)
|
||||
const selectedDate = ref('') // 当前选择的日期 (YYYY-MM-DD)
|
||||
const dataTimestamp = ref('') // 用于在标题旁显示具体的时分秒
|
||||
const chartModules = ref([])
|
||||
const emptyText = ref('暂无数据')
|
||||
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||
|
||||
// ECharts 实例管理
|
||||
let chartInstances = []
|
||||
const chartRefs = ref([])
|
||||
const setChartRef = (el, index) => { if (el) chartRefs.value[index] = el }
|
||||
|
||||
// 禁止选择未来日期
|
||||
const disabledDate = (time) => {
|
||||
return time.getTime() > Date.now()
|
||||
}
|
||||
|
||||
// 格式化设备名称
|
||||
const formatDisplayName = (name) => (name ? name.toUpperCase().replace(/_/g, ' ') : '')
|
||||
|
||||
// 辅助函数:获取今天日期的字符串
|
||||
const getTodayString = () => {
|
||||
const today = new Date()
|
||||
const y = today.getFullYear()
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(today.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
// --- 核心入口:供父组件调用 ---
|
||||
const open = (row) => {
|
||||
visible.value = true
|
||||
deviceName.value = row.name
|
||||
currentSource.value = row.source
|
||||
chartModules.value = []
|
||||
|
||||
// --- 逻辑修改核心:默认展示数据库最新一条数据 ---
|
||||
// row.latest_time 格式通常为 "2026-01-08 16:29:28"
|
||||
if (row.latest_time && row.latest_time !== 'N/A') {
|
||||
// 1. 保存完整时间用于显示
|
||||
dataTimestamp.value = row.latest_time
|
||||
|
||||
// 2. 提取日期部分 (YYYY-MM-DD) 赋值给 DatePicker
|
||||
// 兼容空格分隔或 T 分隔
|
||||
try {
|
||||
const datePart = row.latest_time.split(' ')[0].split('T')[0]
|
||||
selectedDate.value = datePart
|
||||
} catch (e) {
|
||||
console.warn('时间格式解析异常,回退到今天', row.latest_time)
|
||||
selectedDate.value = getTodayString()
|
||||
}
|
||||
} else {
|
||||
// 如果没有历史记录,默认显示今天,且不显示具体时间点
|
||||
selectedDate.value = getTodayString()
|
||||
dataTimestamp.value = ''
|
||||
}
|
||||
|
||||
// 3. 此时 selectedDate 已自动同步为最新数据的日期,直接加载
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 日期改变时重新加载
|
||||
const handleDateChange = () => {
|
||||
// 用户手动切换日期时,清空具体时间显示(因为我们只知道日期,不知道该日期的具体时间点)
|
||||
dataTimestamp.value = ''
|
||||
loadData()
|
||||
}
|
||||
|
||||
// --- 数据加载逻辑 ---
|
||||
const loadData = async () => {
|
||||
if (!deviceName.value || !selectedDate.value) return
|
||||
|
||||
loading.value = true
|
||||
chartModules.value = []
|
||||
emptyText.value = '加载中...'
|
||||
disposeCharts() // 销毁旧图表实例
|
||||
|
||||
try {
|
||||
// 发起请求:根据设备名和日期获取数据
|
||||
const res = await axios.get(`${API_BASE}/api/device_data_by_date`, {
|
||||
params: {
|
||||
name: deviceName.value,
|
||||
date: selectedDate.value
|
||||
}
|
||||
})
|
||||
|
||||
const { content, source } = res.data
|
||||
|
||||
// [关键容错] 优先使用接口返回的 source,若接口未返回则使用列表页传来的 source
|
||||
// 这决定了是使用 106正则解析 还是 82JSON解析
|
||||
const effectiveSource = source || currentSource.value
|
||||
|
||||
if (!content || content === '{}' || content === 'null') {
|
||||
emptyText.value = `${selectedDate.value} 无数据记录`
|
||||
} else {
|
||||
// 解析数据
|
||||
const modules = parseChartData({
|
||||
name: deviceName.value,
|
||||
content,
|
||||
source: effectiveSource
|
||||
})
|
||||
|
||||
chartModules.value = modules
|
||||
|
||||
if (modules.length === 0) {
|
||||
emptyText.value = '数据解析失败 (格式不匹配)'
|
||||
} else {
|
||||
// 等待 DOM 更新后渲染图表
|
||||
await nextTick()
|
||||
initCharts()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.response && e.response.status === 404) {
|
||||
emptyText.value = `${selectedDate.value} 无数据记录`
|
||||
} else {
|
||||
console.error('Data Load Error:', e)
|
||||
ElMessage.error('获取详细数据失败')
|
||||
emptyText.value = '请求出错'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 数据解析逻辑 ---
|
||||
|
||||
// 1. 解析 106 类型数据 (正则解析)
|
||||
function parse106Data(content) {
|
||||
if (typeof content !== 'string') return []
|
||||
const modules = []
|
||||
// 匹配 Model, SN 和 波长信息
|
||||
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
|
||||
let match
|
||||
|
||||
while ((match = infoRegex.exec(content)) !== null) {
|
||||
const model = match[1]
|
||||
const sn = match[2]
|
||||
// 处理波长数组
|
||||
const wavelengths = match[3].split(',').map(Number).filter((n) => !isNaN(n))
|
||||
const series = []
|
||||
|
||||
// 提取 P1 到 P4 的数据
|
||||
for (let p = 1; p <= 4; p++) {
|
||||
const dMatch = content.match(
|
||||
new RegExp(`${model.trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
|
||||
)
|
||||
if (dMatch) {
|
||||
const vals = dMatch[1].split(',').map((v) => parseFloat(v))
|
||||
if (vals.some((v) => v !== null && !isNaN(v))) {
|
||||
series.push({
|
||||
name: `P${p}`,
|
||||
data: vals,
|
||||
color: ['#5470c6', '#91cc75', '#fac858', '#ee6666'][p - 1],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (series.length) {
|
||||
modules.push({ type: '106', model, sn, xAxis: wavelengths, series })
|
||||
}
|
||||
}
|
||||
return modules
|
||||
}
|
||||
|
||||
// 2. 解析 82 类型数据 (JSON解析)
|
||||
function parse82Data(content, deviceName) {
|
||||
try {
|
||||
const d = typeof content === 'string' ? JSON.parse(content) : content
|
||||
// 兼容 wavelenth 和 wavelength 拼写
|
||||
if (d && (d.wavelenth || d.wavelength)) {
|
||||
const xData = d.wavelenth || d.wavelength
|
||||
return [{
|
||||
type: '82',
|
||||
title: deviceName,
|
||||
xAxis: xData,
|
||||
series: [
|
||||
{ name: 'DownSpec', data: d.downspec, color: '#409EFF' },
|
||||
{ name: 'UpSpec', data: d.upspec, color: '#67C23A' },
|
||||
],
|
||||
}]
|
||||
}
|
||||
return []
|
||||
} catch (e) {
|
||||
console.warn('JSON Parse 82 Error', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 统一解析入口
|
||||
function parseChartData(device) {
|
||||
if (!device || !device.content) return []
|
||||
const is106Site = device.source && device.source.includes('106')
|
||||
|
||||
if (is106Site) {
|
||||
return parse106Data(device.content)
|
||||
} else {
|
||||
return parse82Data(device.content, device.name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ECharts 渲染逻辑 ---
|
||||
|
||||
function getChartOption(moduleData, isMobile = false) {
|
||||
const titleText = moduleData.type === '106'
|
||||
? `Model: ${moduleData.model} (SN: ${moduleData.sn})`
|
||||
: moduleData.title
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: titleText,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: { fontSize: isMobile ? 14 : 16 },
|
||||
},
|
||||
tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'cross' } },
|
||||
legend: { top: 35, type: 'scroll' },
|
||||
toolbox: { feature: { saveAsImage: { title: '保存' }, dataZoom: { title: { zoom: '缩放', back: '还原' } } } },
|
||||
grid: { top: 80, bottom: 30, right: isMobile ? 10 : 40, left: isMobile ? 40 : 50 },
|
||||
xAxis: { type: 'category', data: moduleData.xAxis, boundaryGap: false, name: 'nm' },
|
||||
yAxis: { type: 'value', min: 'dataMin', max: 'dataMax', scale: true },
|
||||
series: moduleData.series.map((s) => ({
|
||||
name: s.name, type: 'line', data: s.data, connectNulls: false, smooth: true, showSymbol: false,
|
||||
lineStyle: { width: 1.5, color: s.color }, areaStyle: { opacity: 0.1, color: s.color },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const initCharts = () => {
|
||||
if (chartModules.value.length === 0) return
|
||||
const isMobile = window.innerWidth < 768
|
||||
chartModules.value.forEach((mod, index) => {
|
||||
const el = chartRefs.value[index]
|
||||
if (el) {
|
||||
// 防止重复初始化
|
||||
if (echarts.getInstanceByDom(el)) echarts.getInstanceByDom(el).dispose()
|
||||
const chart = echarts.init(el)
|
||||
chart.setOption(getChartOption(mod, isMobile))
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
chartInstances.forEach((chart) => chart && chart.dispose())
|
||||
chartInstances = []
|
||||
chartRefs.value = []
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
onBeforeUnmount(() => disposeCharts())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-header-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #EBEEF5;
|
||||
}
|
||||
.device-info { display: flex; align-items: center; gap: 10px; }
|
||||
.d-name { font-size: 18px; font-weight: bold; color: #303133; }
|
||||
.latest-time-hint { font-size: 12px; color: #909399; margin-left: 5px; }
|
||||
|
||||
.date-filter { display: flex; align-items: center; }
|
||||
.label { font-size: 14px; color: #606266; margin-right: 8px; }
|
||||
|
||||
.monitor-dialog-content { min-height: 400px; padding: 10px; }
|
||||
.charts-scroll-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.chart-wrapper {
|
||||
background: #fff; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
padding: 10px; border: 1px solid #EBEEF5;
|
||||
}
|
||||
.echart-container { width: 100%; height: 450px; }
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.dialog-header-bar { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.echart-container { height: 350px; }
|
||||
}
|
||||
</style>
|
||||
177
zhandianxinxi/光谱数据监控/src/views/FileHistoryDialog.vue
Normal file
177
zhandianxinxi/光谱数据监控/src/views/FileHistoryDialog.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
align-center
|
||||
@closed="handleClose"
|
||||
>
|
||||
<div class="history-container">
|
||||
<el-table
|
||||
:data="paginatedData"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
height="400"
|
||||
>
|
||||
<el-table-column prop="date" label="数据日期" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-icon><Calendar /></el-icon> {{ row.dateDisplay }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="count" label="文件个数" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.count > 0 ? 'primary' : 'info'" effect="plain">
|
||||
{{ row.count }} 个
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-config-provider :locale="zhCn">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
background
|
||||
/>
|
||||
</el-config-provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { Calendar } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentDeviceName = ref('')
|
||||
const dialogTitle = ref('')
|
||||
|
||||
// 数据源
|
||||
const allTableData = ref([]) // 存放去重、排序后的所有数据
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0) // 这里的 total 是去重后的总天数
|
||||
|
||||
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||
|
||||
// 格式化工具:提取 YYYY-MM-DD
|
||||
const getDayKey = (raw) => {
|
||||
if (!raw) return ''
|
||||
// 兼容 2026_02_03 和 2026-02-03 格式,且去掉时分秒
|
||||
return raw.replace(/_/g, '-').split(' ')[0]
|
||||
}
|
||||
|
||||
// 计算属性:实现前端分页切片
|
||||
// 这里的逻辑是:从“总数据”中,切出“当前页”需要显示的那几条
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return allTableData.value.slice(start, end)
|
||||
})
|
||||
|
||||
const open = (device) => {
|
||||
if (!device || !device.name) return
|
||||
currentDeviceName.value = device.name
|
||||
dialogTitle.value = `📜 ${formatDisplayName(device.name)} - 历史文件记录`
|
||||
visible.value = true
|
||||
currentPage.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 关键点:为了让前端能准确去重和排序,这里 limit 设得很大,意图拉取“全部”或“近期所有”数据
|
||||
// 如果不拉全量数据,就无法保证跨页去重的准确性
|
||||
const res = await axios.get(`${API_BASE}/api/device_history_list`, {
|
||||
params: {
|
||||
name: currentDeviceName.value,
|
||||
page: 1,
|
||||
limit: 1000 // 拉取足够多的数据在前端处理
|
||||
}
|
||||
})
|
||||
|
||||
if (res.data.code === 200) {
|
||||
const rawList = res.data.data || []
|
||||
|
||||
// 1. 分组去重:同一天取 count 最大的
|
||||
const dateMap = new Map()
|
||||
|
||||
rawList.forEach(item => {
|
||||
const dayStr = getDayKey(item.date)
|
||||
if (!dayStr) return
|
||||
|
||||
if (dateMap.has(dayStr)) {
|
||||
const exist = dateMap.get(dayStr)
|
||||
// 如果当前数据的 count 比已记录的大,就替换掉
|
||||
if (item.count > exist.count) {
|
||||
// 保留原始 item,并附加格式化好的日期方便展示
|
||||
dateMap.set(dayStr, { ...item, dateDisplay: dayStr })
|
||||
}
|
||||
} else {
|
||||
dateMap.set(dayStr, { ...item, dateDisplay: dayStr })
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 转为数组
|
||||
const processedList = Array.from(dateMap.values())
|
||||
|
||||
// 3. 强制排序:按日期字符串降序 (2026-02-03 -> 2026-02-01)
|
||||
processedList.sort((a, b) => {
|
||||
return b.dateDisplay.localeCompare(a.dateDisplay)
|
||||
})
|
||||
|
||||
// 4. 赋值
|
||||
allTableData.value = processedList
|
||||
|
||||
// 5. 修正 Total:现在的 Total 是“有多少个不同的日期”,而不是后端的 raw total
|
||||
total.value = processedList.length
|
||||
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '获取历史记录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('网络请求异常')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
allTableData.value = []
|
||||
}
|
||||
|
||||
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-container {
|
||||
padding: 10px;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
346
zhandianxinxi/光谱数据监控/src/views/IoTDeviceBinder.vue
Normal file
346
zhandianxinxi/光谱数据监控/src/views/IoTDeviceBinder.vue
Normal file
@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="🔗 IoT 卡片管理与绑定"
|
||||
width="1000px"
|
||||
top="8vh"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="binder-container">
|
||||
|
||||
<div class="tips-alert">
|
||||
<el-alert title="功能说明" type="info" show-icon :closable="false">
|
||||
<template #default>
|
||||
<div>1. <b>白名单置顶:</b> 开启白名单的卡片会自动排在列表最上方。</div>
|
||||
<div>2. <b>绑定要求:</b> 目标设备必须是系统中已存在的设备。</div>
|
||||
<div>3. <b>流量警告:</b> 白名单卡片即使流量超过 500M 也不会触发黄色警告。</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-radio-group v-model="filterStatus" @change="filterList" style="margin-right: 15px;">
|
||||
<el-radio-button label="all">全部卡片</el-radio-button>
|
||||
<el-radio-button label="unbound">待绑定 (孤儿卡)</el-radio-button>
|
||||
<el-radio-button label="bound">已绑定</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索 ICCID 或 设备名..."
|
||||
style="width: 250px"
|
||||
clearable
|
||||
@input="filterList"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-button type="primary" plain icon="Refresh" @click="fetchIoTDevices" style="margin-left: auto;">刷新数据</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="displayList"
|
||||
border
|
||||
stripe
|
||||
v-loading="loading"
|
||||
height="500"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-table-column label="ICCID (卡号)" prop="iccid" width="240" sortable>
|
||||
<template #default="{ row }">
|
||||
<span class="iccid-text">{{ row.iccid }}</span>
|
||||
<el-tag v-if="row.tag" size="small" type="info" style="margin-left:5px">{{ row.tag }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="关联设备状态" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isEditing" class="edit-cell">
|
||||
<el-autocomplete
|
||||
v-model="row.targetDeviceName"
|
||||
:fetch-suggestions="querySearchDevice"
|
||||
placeholder="请输入并选择设备..."
|
||||
size="small"
|
||||
style="width: 180px;"
|
||||
@select="handleSelectDevice"
|
||||
@keyup.enter="saveBinding(row)"
|
||||
ref="nameInputRef"
|
||||
:trigger-on-focus="true"
|
||||
clearable
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="suggestion-item">
|
||||
<span class="device-name-highlight">{{ item.value }}</span>
|
||||
<span class="suggestion-site">{{ item.site }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<el-button type="success" size="small" icon="Check" circle @click="saveBinding(row)" :loading="row.saving" />
|
||||
<el-button type="info" size="small" icon="Close" circle @click="cancelEdit(row)" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="row.boundDeviceName" class="bound-cell">
|
||||
<el-tag type="success" effect="plain" class="bound-tag">
|
||||
<el-icon><Link /></el-icon> 已关联: {{ row.boundDeviceName }}
|
||||
</el-tag>
|
||||
<el-button type="primary" link size="small" icon="Edit" @click="startEdit(row)">修改</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="unbound-cell" @click="startEdit(row)">
|
||||
<span class="placeholder-text">🔴 尚未关联,点击绑定...</span>
|
||||
<el-icon class="edit-icon"><EditPen /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="当月用量" width="120" align="right" sortable prop="usedTrafficNum">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: row.usedTrafficNum >= 500 && !row.isWhitelist ? 'bold' : 'normal', color: row.usedTrafficNum >= 500 && !row.isWhitelist ? '#E6A23C' : '#606266' }">
|
||||
{{ row.usedTraffic }} M
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="白名单" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.isWhitelist"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
inline-prompt
|
||||
:loading="row.whitelistLoading"
|
||||
:before-change="() => toggleWhitelist(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visible = false">关 闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||
import { Search, Refresh, EditPen, Check, Close, Link } from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const fullSimList = ref([])
|
||||
const displayList = ref([])
|
||||
const allDeviceNames = ref([])
|
||||
const keyword = ref('')
|
||||
const filterStatus = ref('all')
|
||||
|
||||
const emit = defineEmits(['update-success'])
|
||||
|
||||
const open = () => {
|
||||
visible.value = true
|
||||
filterStatus.value = 'all'
|
||||
fetchIoTDevices()
|
||||
}
|
||||
|
||||
// 本地排序逻辑
|
||||
const applySort = () => {
|
||||
fullSimList.value.sort((a, b) => {
|
||||
// 1. 白名单 (True 在前)
|
||||
if (a.isWhitelist !== b.isWhitelist) {
|
||||
return a.isWhitelist ? -1 : 1
|
||||
}
|
||||
// 2. 绑定状态
|
||||
const aBound = !!a.boundDeviceName
|
||||
const bBound = !!b.boundDeviceName
|
||||
if (aBound !== bBound) {
|
||||
return aBound ? -1 : 1
|
||||
}
|
||||
// 3. 默认顺序
|
||||
return a.iccid.localeCompare(b.iccid)
|
||||
})
|
||||
// 重新执行过滤以应用排序到显示列表
|
||||
filterList()
|
||||
}
|
||||
|
||||
const fetchIoTDevices = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/api/devices_overview`)
|
||||
const allData = res.data.data || []
|
||||
|
||||
const iccidToDeviceMap = {}
|
||||
const realDevices = []
|
||||
|
||||
allData.forEach(d => {
|
||||
if (d.source !== 'iot_card') {
|
||||
realDevices.push({ value: d.name, site: d.install_site || '未填地点' })
|
||||
if (d.bound_iccid) {
|
||||
iccidToDeviceMap[d.bound_iccid] = d.name
|
||||
}
|
||||
}
|
||||
})
|
||||
allDeviceNames.value = realDevices
|
||||
|
||||
const cards = allData.filter(d => d.source === 'iot_card')
|
||||
|
||||
const tempList = cards.map(c => {
|
||||
const iccid = c.name
|
||||
let j = {}
|
||||
try { j = JSON.parse(c.json_data || '{}') } catch(e){}
|
||||
const ownerDevice = iccidToDeviceMap[iccid] || ''
|
||||
|
||||
let traffic = c.usedTraffic || j.usedTraffic || '0'
|
||||
let trafficNum = parseFloat(traffic) || 0
|
||||
|
||||
let isW = false
|
||||
if (j.is_whitelist !== undefined) isW = j.is_whitelist
|
||||
if (c.is_whitelist !== undefined) isW = c.is_whitelist
|
||||
|
||||
return {
|
||||
iccid: iccid,
|
||||
tag: j.tag || '',
|
||||
usedTraffic: traffic,
|
||||
usedTrafficNum: trafficNum,
|
||||
boundDeviceName: ownerDevice,
|
||||
targetDeviceName: ownerDevice,
|
||||
status: c.status || 'offline',
|
||||
isWhitelist: !!isW,
|
||||
isEditing: false,
|
||||
saving: false,
|
||||
whitelistLoading: false
|
||||
}
|
||||
})
|
||||
|
||||
fullSimList.value = tempList
|
||||
applySort()
|
||||
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filterList = () => {
|
||||
let list = fullSimList.value
|
||||
if (filterStatus.value === 'bound') list = list.filter(i => i.boundDeviceName)
|
||||
if (filterStatus.value === 'unbound') list = list.filter(i => !i.boundDeviceName)
|
||||
if (keyword.value) {
|
||||
const k = keyword.value.toLowerCase()
|
||||
list = list.filter(i => i.iccid.toLowerCase().includes(k) || (i.boundDeviceName && i.boundDeviceName.toLowerCase().includes(k)))
|
||||
}
|
||||
displayList.value = list
|
||||
}
|
||||
|
||||
const startEdit = (row) => {
|
||||
displayList.value.forEach(i => i.isEditing = false)
|
||||
row.targetDeviceName = row.boundDeviceName || ''
|
||||
row.isEditing = true
|
||||
nextTick(() => document.querySelector('.edit-cell input')?.focus())
|
||||
}
|
||||
|
||||
const cancelEdit = (row) => {
|
||||
row.isEditing = false
|
||||
row.targetDeviceName = row.boundDeviceName
|
||||
}
|
||||
|
||||
const querySearchDevice = (qs, cb) => {
|
||||
const res = qs ? allDeviceNames.value.filter(i => i.value.toLowerCase().indexOf(qs.toLowerCase()) > -1) : allDeviceNames.value
|
||||
cb(res)
|
||||
}
|
||||
const handleSelectDevice = (item) => {}
|
||||
|
||||
const saveBinding = async (row) => {
|
||||
const target = row.targetDeviceName
|
||||
if (!target) return ElMessage.warning('请输入设备名')
|
||||
const exists = allDeviceNames.value.some(d => d.value === target)
|
||||
if (!exists) return ElMessage.error('设备不存在,请先新建设备')
|
||||
|
||||
row.saving = true
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/bind_device_card`, { iccid: row.iccid, device_name: target })
|
||||
ElMessage.success('绑定成功')
|
||||
row.boundDeviceName = target
|
||||
row.isEditing = false
|
||||
emit('update-success')
|
||||
fetchIoTDevices()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.message || '失败')
|
||||
} finally {
|
||||
row.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// [核心修复] 白名单切换逻辑
|
||||
// ---------------------------------------------------------
|
||||
const toggleWhitelist = (row) => {
|
||||
// 设置 Loading,防止重复点击
|
||||
row.whitelistLoading = true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 预期的新状态 (当前状态取反)
|
||||
const targetVal = !row.isWhitelist
|
||||
|
||||
axios.post(`${API_BASE}/api/toggle_whitelist`, {
|
||||
iccid: row.iccid,
|
||||
is_whitelist: targetVal
|
||||
}).then(() => {
|
||||
// 1. API 成功
|
||||
ElMessage.success(targetVal ? '已加入白名单' : '已移出白名单')
|
||||
|
||||
// 2. 触发父组件更新 (Dashboard 计数等)
|
||||
emit('update-success')
|
||||
|
||||
// 3. 关键:resolve(true) 会告诉 el-switch 组件可以切换视觉状态了
|
||||
// 此时 Vue 会自动更新 v-model (即 row.isWhitelist) 的值
|
||||
// 我们不需要在这里手动写 row.isWhitelist = targetVal
|
||||
resolve(true)
|
||||
|
||||
row.whitelistLoading = false
|
||||
|
||||
// 4. 延迟触发排序
|
||||
// 为什么要延迟?
|
||||
// A. 等待 el-switch 动画播放
|
||||
// B. 确保 v-model 的值已经确实更新到了 row 对象上
|
||||
setTimeout(() => {
|
||||
applySort()
|
||||
}, 300)
|
||||
|
||||
}).catch(() => {
|
||||
// 失败:El-switch 保持原状
|
||||
ElMessage.error('操作失败')
|
||||
row.whitelistLoading = false
|
||||
reject(new Error('Failed'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => { visible.value = false }
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.binder-container { padding: 5px; }
|
||||
.toolbar { display: flex; align-items: center; margin-bottom: 15px; }
|
||||
.iccid-text { font-family: monospace; font-weight: bold; color: #606266; }
|
||||
.bound-cell { display: flex; align-items: center; justify-content: space-between; }
|
||||
.unbound-cell { cursor: pointer; color: #909399; font-style: italic; font-size: 13px; display:flex; align-items:center; justify-content:space-between; padding:4px 8px; border:1px dashed transparent; border-radius:4px; }
|
||||
.unbound-cell:hover { background-color: #f0f9ff; border-color: #a0cfff; color: #409EFF; }
|
||||
.edit-cell { display: flex; align-items: center; gap: 5px; }
|
||||
.suggestion-item { display: flex; justify-content: space-between; width: 100%; }
|
||||
.suggestion-site { color: #999; font-size: 12px; }
|
||||
.empty-tip { text-align: center; color: #909399; padding: 40px; }
|
||||
</style>
|
||||
303
zhandianxinxi/光谱数据监控/src/views/MaintenanceLogs.vue
Normal file
303
zhandianxinxi/光谱数据监控/src/views/MaintenanceLogs.vue
Normal file
@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="🔧 维修与故障日志中心"
|
||||
width="85%"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<div class="logs-container">
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="fetchLogs"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索:设备名 / 工程师 / 内容"
|
||||
style="width: 300px"
|
||||
clearable
|
||||
@clear="fetchLogs"
|
||||
@keyup.enter="fetchLogs"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="fetchLogs">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<el-button type="success" icon="Plus" @click="openAddDialog">新增记录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="logsList"
|
||||
border
|
||||
stripe
|
||||
v-loading="loading"
|
||||
height="550"
|
||||
style="width: 100%; margin-top: 15px"
|
||||
>
|
||||
<el-table-column prop="timestamp" label="记录时间" width="170" sortable />
|
||||
|
||||
<el-table-column prop="device_name" label="设备名称" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="plain">{{ formatDisplayName(row.device_name) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="location" label="地点" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="engineer" label="工程师" width="120" />
|
||||
<el-table-column prop="content" label="维修/故障详情" min-width="300" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
icon="Edit"
|
||||
@click="openEditDialog(row)"
|
||||
style="margin-right: 5px;"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
|
||||
<el-popconfirm title="确定删除这条记录吗?" @confirm="deleteLog(row.id)">
|
||||
<template #reference>
|
||||
<el-button type="danger" link icon="Delete">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="logDialog.visible"
|
||||
:title="logDialog.isEdit ? '✏️ 修改维修记录' : '📝 新增维修记录'"
|
||||
width="500px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form :model="logDialog.form" label-width="80px" label-position="top">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备名称">
|
||||
<el-input
|
||||
v-model="logDialog.form.device_name"
|
||||
placeholder="例: 106_Tower"
|
||||
:disabled="logDialog.isEdit"
|
||||
/>
|
||||
<div v-if="logDialog.isEdit" class="form-tip">
|
||||
<el-icon><InfoFilled /></el-icon> 为了数据追溯,修改模式下禁止更改关联设备
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="工程师">
|
||||
<el-input v-model="logDialog.form.engineer" placeholder="例: 张工" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="地点">
|
||||
<el-input v-model="logDialog.form.location" placeholder="例: 3号楼顶层" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="事件内容">
|
||||
<el-input
|
||||
v-model="logDialog.form.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="描述故障原因及处理结果..."
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="logDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitLog" :loading="logDialog.submitting">
|
||||
{{ logDialog.isEdit ? '保存修改' : '提交保存' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</el-dialog>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||
import { Search, Plus, Delete, Edit, InfoFilled } from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||
|
||||
// --- 核心状态 ---
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const logsList = ref([])
|
||||
const keyword = ref('')
|
||||
const dateRange = ref([])
|
||||
|
||||
// 弹窗状态封装
|
||||
const logDialog = reactive({
|
||||
visible: false,
|
||||
submitting: false,
|
||||
isEdit: false,
|
||||
form: {
|
||||
id: null,
|
||||
device_name: '',
|
||||
engineer: '',
|
||||
location: '',
|
||||
content: ''
|
||||
}
|
||||
})
|
||||
|
||||
// --- 方法逻辑 ---
|
||||
|
||||
// 1. 暴露给父组件的打开方法
|
||||
const open = (prefillData = null) => {
|
||||
visible.value = true
|
||||
// 如果从设备卡片点击进来,自动筛选该设备
|
||||
if (prefillData && prefillData.deviceName) {
|
||||
keyword.value = prefillData.deviceName
|
||||
}
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
// 2. 获取数据列表
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { keyword: keyword.value }
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const res = await axios.get(`${API_BASE}/api/logs/list`, { params })
|
||||
logsList.value = res.data.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载日志中心数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 处理新增
|
||||
const openAddDialog = () => {
|
||||
logDialog.isEdit = false
|
||||
logDialog.form = {
|
||||
id: null,
|
||||
// 自动带入当前的搜索词作为设备名,提高录入效率
|
||||
device_name: keyword.value || '',
|
||||
engineer: '',
|
||||
location: '',
|
||||
content: ''
|
||||
}
|
||||
logDialog.visible = true
|
||||
}
|
||||
|
||||
// 4. 处理修改
|
||||
const openEditDialog = (row) => {
|
||||
logDialog.isEdit = true
|
||||
logDialog.form = {
|
||||
id: row.id,
|
||||
device_name: row.device_name,
|
||||
engineer: row.engineer,
|
||||
location: row.location,
|
||||
content: row.content
|
||||
}
|
||||
logDialog.visible = true
|
||||
}
|
||||
|
||||
// 5. 提交表单(核心逻辑区分)
|
||||
const submitLog = async () => {
|
||||
if (!logDialog.form.device_name || !logDialog.form.content) {
|
||||
return ElMessage.warning('设备名称和事件内容为必填项')
|
||||
}
|
||||
|
||||
logDialog.submitting = true
|
||||
try {
|
||||
const endpoint = logDialog.isEdit ? '/api/logs/update' : '/api/logs/add'
|
||||
await axios.post(`${API_BASE}${endpoint}`, logDialog.form)
|
||||
|
||||
ElMessage.success(logDialog.isEdit ? '日志已成功修改' : '日志已添加')
|
||||
logDialog.visible = false
|
||||
fetchLogs() // 刷新列表
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败,请检查网络或后端服务')
|
||||
} finally {
|
||||
logDialog.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 删除逻辑
|
||||
const deleteLog = async (id) => {
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/logs/delete`, { id })
|
||||
ElMessage.success('记录已安全删除')
|
||||
fetchLogs()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化名称工具
|
||||
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||
|
||||
// 暴露方法给父组件 Dashboard 调用
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 调整输入框禁用时的样式,保持可读性 */
|
||||
:deep(.el-input.is-disabled .el-input__wrapper) {
|
||||
background-color: #f5f7fa;
|
||||
box-shadow: 0 0 0 1px #e4e7ed inset;
|
||||
}
|
||||
|
||||
:deep(.el-input.is-disabled .el-input__inner) {
|
||||
color: #606266;
|
||||
-webkit-text-fill-color: #606266;
|
||||
}
|
||||
</style>
|
||||
65
zhandianxinxi/光谱数据监控/src/views/login.vue
Normal file
65
zhandianxinxi/光谱数据监控/src/views/login.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card">
|
||||
<h2>🚀 设备监控系统登录</h2>
|
||||
<el-form :model="loginForm" @keyup.enter="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" style="width: 100%" @click="handleLogin">登录</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const loginForm = ref({ username: '', password: '' })
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
return ElMessage.warning('请输入用户名和密码')
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.post('/api/login', loginForm.value)
|
||||
if (res.data.code === 200) {
|
||||
// 存储登录状态
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
localStorage.setItem('token', res.data.token)
|
||||
ElMessage.success('欢迎回来')
|
||||
router.push('/dashboard') // 登录成功跳转
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #1d2b64 0%, #f8cdda 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
30
zhandianxinxi/光谱数据监控/vite.config.js
Normal file
30
zhandianxinxi/光谱数据监控/vite.config.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
// --- 强烈建议新增这一行 ---
|
||||
// 这确保 index.html 引用 css/js 时使用相对路径,
|
||||
// 避免 Flask 托管时出现找不到文件的 404 错误。
|
||||
base: './',
|
||||
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
|
||||
// --- 关于这段 server 配置 ---
|
||||
// 这里的配置仅在你自己电脑上写代码(npm run dev)时有效。
|
||||
// 打包(npm run build)后,前端请求会直接发给同源的 Flask,
|
||||
// 所以这里填什么 IP 对打包后的程序没有影响,不用改。
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:5000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user