12 Commits

60 changed files with 63285 additions and 2149 deletions

View 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,
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View 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)

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
1.1/dabao/dist/index.html vendored Normal file
View 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
View 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

Binary file not shown.

327
1.1/dabao/test1.py Normal file
View 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):
# 如果是打包后的 exesys.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)

Binary file not shown.

135
1.1/frps.py Normal file
View 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
View 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
View 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):
# 如果是打包后的 exesys.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
View 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()

View File

291
1.1/整合.py Normal file
View File

@ -0,0 +1,291 @@
import os
import json
import time
import requests
import pandas as pd
from lxml import etree
from datetime import datetime
# --- 基础配置 ---
DATA_ROOT = "data"
FRPS_DIR = os.path.join(DATA_ROOT, "frps_106")
WEATHER_DIR = os.path.join(DATA_ROOT, "weather_82")
EXCEL_PATH = os.path.join(DATA_ROOT, "error_report.xlsx")
for d in [FRPS_DIR, WEATHER_DIR]:
os.makedirs(d, exist_ok=True)
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'}
}
}
error_logs = []
# --- 通用工具函数 ---
def add_error(source, name, reason, latest_time="N/A"):
"""记录错误并计算日期差"""
days_diff = "N/A"
if latest_time and latest_time != "N/A":
try:
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()
diff = (today_date - target_date).days
days_diff = f"滞后 {diff}" if diff > 0 else "当天已同步"
except:
days_diff = "解析失败"
error_logs.append({
"数据来源": source,
"站点/代理名称": name,
"错误原因": reason,
"日期偏移量": days_diff,
"最新数据时间": latest_time,
"检查时间": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
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
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', '')
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, target_str))
except:
continue
if not scored_items: return None
scored_items.sort(key=lambda x: x[0])
return scored_items[0]
def process_text_content(raw_content):
if not raw_content: return ""
lines = str(raw_content).split('\n')
result, current = [], ""
for line in lines:
if " " in line:
current += line.strip()
else:
if current: result.append(current)
current = line.strip()
if current: result.append(current)
return "\n".join(result)
def save_json(folder, name, data):
path = os.path.join(folder, f"{name}.json")
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False)
# --- 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 - 修正逻辑)...")
c = CONFIG["106"]
today_str = datetime.now().strftime("%Y_%m_%d")
try:
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
proxies = resp.json().get('proxies', [])
for item in proxies:
if not isinstance(item, dict): continue
name = item.get('name', 'Unknown')
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:
conf = item.get('conf') or {}
port = conf.get('remote_port')
if not port:
add_error("106网站", name, "配置错误: 缺少 remote_port")
continue
# 只有 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', [])
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
date_path = f"{api_root}{best_date[2]}/"
# 查找文件夹内最新文件
res3 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
it3 = res3.json().get('items', [])
best_file = find_closest_item(it3, is_date_level=False)
if not best_file:
add_error("106网站", name, "文件夹内无文件", best_date[2])
continue
file_item = best_file[1]
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
# 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)}")
except Exception as e:
add_error("106网站", "全局逻辑", f"主进程崩溃: {str(e)}")
# --- 82 网站逻辑 (保持原样) ---
def run_82_logic():
print("\n>>> 开始处理 82 网站 (Weather)...")
c = CONFIG["82"]
session = requests.Session()
today_fmt = datetime.now().strftime("%Y-%m-%d")
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)
if resp.status_code != 200:
add_error("82网站", "登录模块", f"无法获取列表: HTTP {resp.status_code}")
return
stations = etree.HTML(resp.content).xpath('//option/@value')
stations = [s for s in stations if s and str(s).strip()]
for sid in stations:
try:
r = session.post(f"{c['base_url']}/getLastWeatherData.php", data=str(sid),
headers={'Content-Type': 'text/plain'}, timeout=10)
if r.status_code != 200:
add_error("82网站", sid, f"请求失败: HTTP {r.status_code}")
continue
data = r.json()
if data is None:
add_error("82网站", sid, "返回 Null 数据")
continue
d_list = data.get('date', [])
if not d_list:
add_error("82网站", sid, "返回结果中无日期列表")
else:
latest = str(d_list[-1])
if latest.split()[0] != today_fmt:
add_error("82网站", sid, "数据非当天更新", latest)
save_json(WEATHER_DIR, sid, data)
time.sleep(0.1)
except Exception as e:
add_error("82网站", sid, f"数据解析异常: {str(e)}")
except Exception as e:
add_error("82网站", "初始化模块", str(e))
# --- 汇总导出 ---
def export_to_excel():
if not error_logs:
print("\n[✔] 未发现错误记录。")
return
df = pd.DataFrame(error_logs)
cols = ["数据来源", "站点/代理名称", "错误原因", "日期偏移量", "最新数据时间", "检查时间"]
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)} 条)")
if __name__ == "__main__":
run_106_logic()
run_82_logic()
export_to_excel()
print("\n任务全部完成。")

283
2_1banben/app.py Normal file
View 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
View 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}")

View File

@ -1,10 +1,9 @@
#extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
from flask_apscheduler import APScheduler
from flask_jwt_extended import JWTManager
# 这里只创建对象,不绑定 app
db = SQLAlchemy()
cors = CORS()
scheduler = APScheduler()
jwt = JWTManager()
scheduler = APScheduler()

View File

@ -1,61 +1,53 @@
# models.py
from datetime import datetime
# 引入 UserMixin 是 Flask 标准做法
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from extensions import db
# =======================
# 数据库 1: 业务数据 (默认数据库 / devices.db)
# =======================
class Device(db.Model):
__tablename__ = 'devices'
# 默认数据库,无需 bind_key
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) # 存储完整原始JSON
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)) # 时间偏移量说明
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)
# 🟢 [新增] 维修人字段 (对应你在数据库中新加的列)
maintainer = db.Column(db.String(50))
# 白名单字段 (根据上下文可能存在,补全以防万一)
is_whitelist = db.Column(db.Boolean, default=False)
def to_dict(self):
"""
转换为前端友好的字典格式
"""
# 简单处理状态:只要不是明确的离线/异常,就视为 online
# 统一状态映射逻辑
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, # 给前端图标用的状态 (online/offline)
'status_text': self.status, # 显示在界面的原始状态文字
'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,
# 🟢 [新增] 返回维修人给前端显示
'maintainer': self.maintainer
'file_count': self.file_count # ✅ 返回给前端
}
@ -70,12 +62,15 @@ class DeviceHistory(db.Model):
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))
@ -91,36 +86,4 @@ class MaintenanceLog(db.Model):
'location': self.location or '',
'content': self.content,
'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
}
# =======================
# 数据库 2: 用户管理 (users.db)
# =======================
class User(UserMixin, db.Model):
__bind_key__ = 'users_db' # 指定存储在 users.db
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128))
# 角色: 'admin', 'engineer', 'client'
role = db.Column(db.String(20), default='client')
created_at = db.Column(db.DateTime, default=datetime.now)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class UserDevicePermission(db.Model):
__bind_key__ = 'users_db'
__tablename__ = 'user_device_permissions'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
device_id = db.Column(db.Integer, nullable=False)
}

696
2_1banben/routes/api.py Normal file
View 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)})

View File

@ -7,11 +7,11 @@ web_bp = Blueprint('web', __name__)
@web_bp.route('/')
def index():
"""访问根路径时,返回 dist/index.html"""
"""访问根路径时,返回 web_dist/index.html"""
try:
return send_from_directory(get_static_path(), 'index.html')
except Exception as e:
return f"前端资源未找到,请确认 dist 文件夹是否存在。错误信息: {e}", 404
return f"前端资源未找到,请确认 web_dist 文件夹是否存在。错误信息: {e}", 404
@web_bp.route('/<path:path>')
def static_files(path):

104
2_1banben/services/core.py Normal file
View 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 # 废弃旧逻辑
}

View File

@ -9,6 +9,7 @@ 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):
@ -17,6 +18,7 @@ def get_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)
@ -26,58 +28,82 @@ def get_106_dynamic_token(port):
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():
"""返回 result_list, 每个元素是一个字典"""
"""
106 爬虫主逻辑
返回 result_list, 每个元素是一个字典
"""
results = []
print(">>> [106爬虫] 启动...")
today_str = datetime.now().strftime("%Y_%m_%d")
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': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'target_time': current_standard_time,
'raw_json': {},
'temp_file': None
'temp_file': None,
'num_files': 0
}
# 检查在线状态
if str(item.get('status')).lower() != 'online':
data_packet['status'] = '离线'
data_packet['value'] = f"状态: {item.get('status')}"
@ -85,6 +111,7 @@ def run_106_logic():
continue
try:
# 获取端口和 Token
port = item.get('conf', {}).get('remote_port')
token = get_106_dynamic_token(port)
if not token:
@ -96,31 +123,51 @@ def run_106_logic():
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 or best_date[2] != today_str:
data_packet['value'] = "未找到今日文件夹"
data_packet['target_time'] = best_date[2] if best_date else "N/A"
if not best_date:
data_packet['value'] = "未找到任何日期文件夹"
results.append(data_packet)
continue
data_packet['target_time'] = best_date[2] # 实际数据时间
date_path = f"{api_root}{best_date[2]}/"
# ==============================================================================
# ✅ [核心修复] 时间格式标准化
# 原逻辑: 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)
best_file = find_closest_item(res2.json().get('items', []), False)
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'] = "今日文件夹为空"
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:
@ -129,20 +176,21 @@ def run_106_logic():
with open(temp_path, 'wb') as f:
f.write(res3.content)
data_packet['temp_file'] = temp_path # 🔥 传递给API
data_packet['temp_file'] = temp_path
data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes"
data_packet['raw_json'] = file_item # 用文件属性充当RawData
data_packet['raw_json'] = file_item # 借用 file_item 充当 raw_json
else:
data_packet['status'] = '异常'
data_packet['value'] = f"下载失败: {res3.status_code}"
else:
# JSON 内容
# [文本文件] 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 # 🔥 完整保存
data_packet['value'] = json_content.get('content', '')
data_packet['raw_json'] = json_content
# 尝试提取 content 内容,如果没有则截取部分 JSON 字符串
data_packet['value'] = json_content.get('content', str(json_content)[:100])
except:
data_packet['value'] = "JSON解析失败"
@ -150,7 +198,7 @@ def run_106_logic():
except Exception as e:
data_packet['status'] = '异常'
data_packet['value'] = str(e)[:50]
data_packet['value'] = str(e)[:100]
results.append(data_packet)
except Exception as e:

View 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

View File

@ -1,224 +0,0 @@
import os
import sys
import json
import mimetypes
from datetime import datetime
from flask import Flask, send_from_directory, jsonify
from flask_cors import CORS
# 引入配置
from config import Config
# 引入扩展
from extensions import db, jwt, scheduler
# 引入模型 (确保 create_all 能扫描到)
from models import Device, DeviceHistory, User
# 引入 API 蓝图和工具
try:
from routes.api import api_bp, calculate_offset
except ImportError:
api_bp = None
calculate_offset = None
# 引入爬虫服务
try:
from services.core import execute_monitor_task
except ImportError:
execute_monitor_task = None
# 注册 MIME 类型 (防止前端 JS/CSS 加载报 404 或类型错误)
mimetypes.add_type('application/javascript', '.js')
mimetypes.add_type('text/css', '.css')
# --- 定时任务逻辑 (保持不变) ---
def auto_monitor_job(app):
with app.app_context():
print(f"⏰ [定时任务] 启动: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if not execute_monitor_task:
print("❌ 错误: 爬虫模块未加载")
return
try:
task_result = execute_monitor_task()
if not task_result:
print("⚠️ 未抓取到数据")
return
scraped_list = task_result.get('device_list', [])
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
count = 0
for item in scraped_list:
d_name = item.get('name')
if not d_name: continue
# 查找或创建设备
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()
# 更新状态
device.status = item.get('status')
device.current_value = item.get('value')
device.latest_time = item.get('target_time')
device.check_time = current_time
device.json_data = json.dumps(item.get('raw_json', {}), ensure_ascii=False)
if calculate_offset:
device.offset = calculate_offset(item.get('target_time'))
# 记录历史
db.session.add(DeviceHistory(
device_id=device.id,
status=device.status,
result_data=device.current_value,
data_time=item.get('target_time'),
json_data=device.json_data
))
count += 1
db.session.commit()
print(f"✅ [定时任务] 更新了 {count} 台设备")
except Exception as e:
db.session.rollback()
print(f"❌ [定时任务] 异常: {str(e)}")
# --- App 工厂 ---
def create_app():
app = Flask(__name__, static_folder=Config.STATIC_FOLDER)
# 1. 加载配置 (包含数据库、JWT、爬虫配置)
app.config.from_object(Config)
# 2. 初始化扩展
db.init_app(app)
jwt.init_app(app)
scheduler.init_app(app)
# 3. 配置 CORS (允许 Authorization 头,解决 401/422 的关键)
# 允许所有来源,允许凭证,允许关键 Header
CORS(app,
resources={r"/*": {"origins": "*"}},
supports_credentials=True,
allow_headers=["Content-Type", "Authorization", "X-Requested-With"])
# 4. 注册蓝图
if api_bp:
app.register_blueprint(api_bp)
# ==========================================
# 5. JWT 详细错误处理 (调试核心部分)
# ==========================================
# A. 没带 Token 或者 Header 格式不对
@jwt.unauthorized_loader
def missing_token(error_string):
print(f"\n🔴 [JWT ERROR] 请求被拒绝: 缺少 Token 或格式错误")
print(f" 原因详情: {error_string}")
print(f" 提示: 前端 header 必须是 'Authorization: Bearer <token>'\n")
return jsonify({
"code": 401,
"message": "Missing Authorization Header",
"detail": error_string
}), 401
# B. Token 是坏的 (签名不对,或者被篡改,或者密钥不匹配)
@jwt.invalid_token_loader
def invalid_token(error_string):
print(f"\n🔴 [JWT ERROR] 请求被拒绝: Token 无效 (Invalid)")
print(f" 原因详情: {error_string}")
print(f" 排查: 1. 后端密钥可能变了 2. Token 是旧的 3. 复制粘贴错了\n")
return jsonify({
"code": 401,
"message": "Invalid Token",
"detail": error_string
}), 401
# C. Token 过期了
@jwt.expired_token_loader
def expired_token(jwt_header, jwt_payload):
print(f"\n🔴 [JWT ERROR] 请求被拒绝: Token 已过期 (Expired)")
print(f" 过期 Token 内容: {jwt_payload}")
print(f" 当前服务器时间: {datetime.now()}")
print(f" 提示: 请检查 config.py 里的有效期设置,或校准服务器时间\n")
return jsonify({
"code": 401,
"message": "Token has expired",
"detail": "token_expired"
}), 401
# ==========================================
# 6. 启动定时任务
if execute_monitor_task:
# 防止重复添加任务
if not scheduler.get_job('daily_monitor_task'):
scheduler.add_job(
id='daily_monitor_task',
func=auto_monitor_job,
args=[app],
trigger='cron',
hour=12,
minute=0
)
if not scheduler.running:
scheduler.start()
# 7. 静态文件路由
@app.route('/')
def serve_index():
if not os.path.exists(os.path.join(app.static_folder, 'index.html')):
return "Web files not found. Please build frontend first.", 404
return send_from_directory(app.static_folder, 'index.html')
@app.route('/<path:path>')
def serve_static(path):
file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path):
return send_from_directory(app.static_folder, path)
# 如果不是静态文件请求,也不是 api 请求,就返回 index.html (前端路由)
if path.startswith('api'):
return jsonify({'code': 404, 'msg': 'API endpoint not found'}), 404
return send_from_directory(app.static_folder, 'index.html')
# 8. 初始化数据库和默认管理员
with app.app_context():
# db.create_all() 会根据 binds 配置自动创建 users.db 和 devices.db
db.create_all()
try:
# 检查是否有管理员,没有则创建
if not User.query.filter_by(username='admin').first():
print("🛠️ 正在创建默认管理员账号...")
admin = User(username='admin', role='admin')
admin.set_password('licahk')
db.session.add(admin)
db.session.commit()
print("✅ 初始管理员已创建: admin / licahk")
except Exception as e:
# 捕获数据库连接错误等
print(f"⚠️ 初始化数据警告 (可能是首次运行或表结构变更): {e}")
return app
if __name__ == '__main__':
# 确保在主程序块中运行
app = create_app()
# 判断是否为打包后的环境
debug_mode = not getattr(sys, 'frozen', False)
print(f"\n🚀 服务启动中...")
print(f" 模式: {'Debug (开发)' if debug_mode else 'Production (生产)'}")
print(f" 端口: 5000")
print(f" 密钥检查: {app.config.get('JWT_SECRET_KEY')[:5]}*** (请确保重启后这里不变)\n")
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)

View File

@ -1,59 +0,0 @@
import os
import sys
from datetime import timedelta
def get_base_path():
"""获取运行时路径 (兼容打包后的 exe 和开发环境)"""
if getattr(sys, 'frozen', False):
if hasattr(sys, '_MEIPASS'):
return sys._MEIPASS
else:
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
class Config:
BASE_DIR = get_base_path()
INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance')
# 确保 instance 目录存在
if not os.path.exists(INSTANCE_FOLDER):
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
# 静态文件路径
STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist')
# --- 数据库配置 (整合了 app.py 的逻辑) ---
# 1. 主数据库 (Device, Log 等)
DB_DEVICES_PATH = os.path.join(INSTANCE_FOLDER, 'devices.db')
SQLALCHEMY_DATABASE_URI = f'sqlite:///{DB_DEVICES_PATH}'
# 2. 用户数据库 (User, Permission 等,绑定到 users_db)
DB_USERS_PATH = os.path.join(INSTANCE_FOLDER, 'users.db')
SQLALCHEMY_BINDS = {
'users_db': f'sqlite:///{DB_USERS_PATH}'
}
SQLALCHEMY_TRACK_MODIFICATIONS = False
# --- 🔴 关键修复JWT 配置 (必须设置) ---
JWT_SECRET_KEY = 'super-secret-key-change-this-in-prod-2026'
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1) # Token 1天有效
# --- 定时任务配置 ---
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'}
}
}

View File

@ -1,62 +0,0 @@
import os
from app import create_app
from extensions import db
from models import User, Device, UserDevicePermission
# 创建应用实例
app = create_app()
def init_db():
with app.app_context():
# ==========================================
# ⚠️ 警告:这会清空现有的数据库表结构并重建
# 如果只想更新 User 表,可以注释掉 db.drop_all()
# 但因为增加了字段,直接重建是最稳妥的。
# ==========================================
print("正在清理旧数据库...")
db.drop_all()
print("正在创建新表结构...")
db.create_all()
print("✅ 数据库表结构创建完成 (devices.db 和 users.db)")
# ==========================================
# 🟢 1. 创建超级管理员 (Root)
# 即使代码里有后门,数据库里有一个对应的实体也是最好的
# ==========================================
admin = User(username='admin', role='admin')
admin.set_password('licahk') # 设置密码
db.session.add(admin)
print(f"👤 用户创建: [admin] (角色: 超级管理员)")
# ==========================================
# 🟡 2. 创建一个测试工程师 (可选)
# ==========================================
engineer = User(username='engineer01', role='engineer')
engineer.set_password('123456')
db.session.add(engineer)
print(f"👤 用户创建: [engineer01] (角色: 工程师)")
# ==========================================
# ⚪ 3. 创建一个测试普通客户 (可选)
# ==========================================
client = User(username='client01', role='client')
client.set_password('123456')
db.session.add(client)
print(f"👤 用户创建: [client01] (角色: 客户)")
# 提交更改
db.session.commit()
print("\n🚀 初始化完成!请运行 run.py 启动服务器。")
if __name__ == '__main__':
# 再次确认防止误删
print("此操作会删除现有的 'users.db''devices.db' 中的数据并重建。")
confirm = input("确认继续吗? (y/n): ")
if confirm.lower() == 'y':
init_db()
else:
print("操作已取消。")

Binary file not shown.

Binary file not shown.

View File

@ -1,465 +0,0 @@
import json
import re
from datetime import datetime
from flask import Blueprint, jsonify, request
from sqlalchemy import desc, or_
# 引入 jwt 相关函数
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity
from extensions import db
from models import Device, DeviceHistory, MaintenanceLog, User, UserDevicePermission
# 尝试导入爬虫模块
try:
from services.core import execute_monitor_task
except ImportError:
execute_monitor_task = None
api_bp = Blueprint('api', __name__, url_prefix='/api')
# =======================
# 🔧 辅助函数
# =======================
def is_admin(user_id):
"""判断是否为超级管理员 (Root权限)"""
if str(user_id) == '0':
return True
if not user_id:
return False
try:
uid = int(user_id)
u = User.query.get(uid)
return u and u.role == 'admin'
except:
return False
def is_manager(user_id):
"""判断是否为管理者 (Admin OR Engineer)"""
if is_admin(user_id):
return True
try:
uid = int(user_id)
u = User.query.get(uid)
return u and u.role == 'engineer'
except:
return False
def calculate_offset(latest_time_str):
"""计算时间滞后天数"""
if not latest_time_str or latest_time_str == "N/A": return "从未同步"
try:
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 "时间解析失败"
# =======================
# 0. 认证接口
# =======================
@api_bp.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
# 1. 后门判定
if username == 'admin' and password == 'licahk':
token = create_access_token(
identity='0',
additional_claims={'role': 'admin'}
)
return jsonify({
'code': 200, 'message': 'Root后门登录',
'token': token, 'role': 'admin', 'user_id': 0, 'username': 'admin'
})
# 2. 正常查库登录
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
token = create_access_token(
identity=str(user.id),
additional_claims={'role': user.role}
)
return jsonify({
'code': 200, 'message': '登录成功',
'token': token, 'role': user.role, 'user_id': user.id, 'username': user.username
})
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
# =======================
# 1. 设备接口
# =======================
@api_bp.route('/devices_overview', methods=['GET'])
@jwt_required()
def devices_overview():
try:
user_id = get_jwt_identity()
target_devices = []
# Admin 看所有,其他人看分配
if is_admin(user_id):
target_devices = Device.query.all()
else:
try:
uid_int = int(user_id)
user = User.query.get(uid_int)
if user:
perms = UserDevicePermission.query.filter_by(user_id=user.id).all()
allowed_ids = [p.device_id for p in perms]
if allowed_ids:
target_devices = Device.query.filter(Device.id.in_(allowed_ids)).all()
except ValueError:
return jsonify({'code': 401, 'message': '无效的用户ID格式'}), 401
return jsonify({'code': 200, 'data': [d.to_dict() for d in target_devices]})
except Exception as e:
print(f"Error: {e}")
return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/device_data_by_date', methods=['GET'])
@jwt_required(optional=True)
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 params'}), 400
device = Device.query.filter_by(name=name).first()
if not device: return jsonify({'code': 404, 'message': 'Device not found'}), 404
content = None
hist = DeviceHistory.query.filter(
DeviceHistory.device_id == device.id,
DeviceHistory.data_time.like(f"{date_str}%")
).order_by(desc(DeviceHistory.id)).first()
if hist:
content = hist.json_data
elif device.latest_time and str(device.latest_time).startswith(date_str):
content = device.json_data
if content:
try:
if isinstance(content, str): content = json.loads(content)
except:
pass
return jsonify({'code': 200, 'name': device.name, 'source': device.source, 'content': content})
return jsonify({'code': 404, 'message': '无数据'}), 404
# =======================
# 2. 用户管理 (Admin Only)
# =======================
@api_bp.route('/admin/users', methods=['GET'])
@jwt_required()
def admin_get_users():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
current_id_str = str(get_jwt_identity())
users = User.query.order_by(desc(User.created_at)).all()
result = []
for u in users:
if str(u.id) == current_id_str: continue
perms = UserDevicePermission.query.filter_by(user_id=u.id).all()
result.append({
"id": u.id,
"username": u.username,
"role": u.role,
"created_at": u.created_at,
"allowed_device_ids": [p.device_id for p in perms]
})
return jsonify({'code': 200, 'data': result})
@api_bp.route('/admin/create_user', methods=['POST'])
@jwt_required()
def admin_create_user():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json()
username = data.get('username')
password = data.get('password')
role = data.get('role', 'client')
if User.query.filter_by(username=username).first():
return jsonify({'code': 400, 'msg': '用户名已存在'}), 400
u = User(username=username, role=role)
u.set_password(password)
db.session.add(u)
db.session.commit()
return jsonify({'code': 200, 'msg': '创建成功'})
@api_bp.route('/admin/delete_user', methods=['POST'])
@jwt_required()
def admin_delete_user():
current_admin_id = get_jwt_identity()
if not is_admin(current_admin_id): return jsonify({'code': 403}), 403
user_id = request.get_json().get('user_id')
if str(user_id) == str(current_admin_id):
return jsonify({'code': 400, 'msg': '无法删除当前登录账号'}), 400
user = User.query.get(user_id)
if not user:
return jsonify({'code': 404, 'msg': '用户不存在'}), 404
UserDevicePermission.query.filter_by(user_id=user.id).delete()
db.session.delete(user)
db.session.commit()
return jsonify({'code': 200, 'msg': '删除成功'})
@api_bp.route('/admin/assign_devices', methods=['POST'])
@jwt_required()
def admin_assign_devices():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json()
uid = data.get('user_id')
UserDevicePermission.query.filter_by(user_id=uid).delete()
for did in data.get('device_ids', []):
db.session.add(UserDevicePermission(user_id=uid, device_id=did))
db.session.commit()
return jsonify({'code': 200, 'msg': '权限已保存'})
# =======================
# 3. 日志与工具
# =======================
@api_bp.route('/logs/list', methods=['GET'])
@jwt_required()
def get_logs():
"""获取日志列表,支持按权限过滤"""
user_id = get_jwt_identity()
keyword = request.args.get('keyword', '')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
query = MaintenanceLog.query
# 🛡️ 权限过滤
if not is_admin(user_id):
try:
perms = UserDevicePermission.query.filter_by(user_id=int(user_id)).all()
if not perms:
return jsonify({'code': 200, 'data': []})
allowed_ids = [p.device_id for p in perms]
allowed_devices = Device.query.filter(Device.id.in_(allowed_ids)).all()
allowed_names = [d.name for d in allowed_devices]
query = query.filter(MaintenanceLog.device_name.in_(allowed_names))
except:
return jsonify({'code': 200, 'data': []})
if keyword:
kw = f"%{keyword}%"
query = query.filter(or_(
MaintenanceLog.device_name.like(kw), MaintenanceLog.engineer.like(kw),
MaintenanceLog.location.like(kw), MaintenanceLog.content.like(kw)
))
if start_date and end_date:
try:
s = datetime.strptime(start_date, '%Y-%m-%d')
e = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
query = query.filter(MaintenanceLog.timestamp.between(s, e))
except:
pass
logs = query.order_by(desc(MaintenanceLog.timestamp)).all()
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
@api_bp.route('/logs/add', methods=['POST'])
@jwt_required()
def add_log():
# 获取用户信息
current_uid = get_jwt_identity()
user = User.query.get(int(current_uid))
if not user or user.role not in ['admin', 'engineer']:
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
# 强制逻辑工程师必须用自己的名字Admin可以用前端传的
engineer_name = user.username if user.role == 'engineer' else data.get('engineer')
if not engineer_name:
return jsonify({'code': 400, 'msg': '工程师姓名缺失'}), 400
db.session.add(MaintenanceLog(
device_name=data.get('device_name'),
engineer=engineer_name,
location=data.get('location'),
content=data.get('content')
))
db.session.commit()
return jsonify({'code': 200})
@api_bp.route('/logs/update', methods=['POST'])
@jwt_required()
def update_log():
current_uid = get_jwt_identity()
user = User.query.get(int(current_uid))
if not user or user.role not in ['admin', 'engineer']:
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
log = MaintenanceLog.query.get(data.get('id'))
if not log:
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
engineer_name = user.username if user.role == 'engineer' else data.get('engineer')
if not engineer_name:
return jsonify({'code': 400, 'msg': '工程师姓名缺失'}), 400
log.engineer = engineer_name
log.location = data.get('location')
log.content = data.get('content')
db.session.commit()
return jsonify({'code': 200, 'msg': '更新成功'})
@api_bp.route('/logs/delete', methods=['POST'])
@jwt_required()
def delete_log():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
log = MaintenanceLog.query.get(request.get_json().get('id'))
if log:
db.session.delete(log)
db.session.commit()
return jsonify({'code': 200})
return jsonify({'code': 404})
# =======================
# 4. 系统检测与控制
# =======================
@api_bp.route('/run_monitor', methods=['POST'])
@jwt_required()
def run_monitor():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
if not execute_monitor_task:
return jsonify({'code': 500, 'msg': '爬虫模块未加载'})
try:
task_result = execute_monitor_task()
if not task_result: return jsonify({'code': 200, 'msg': '跳过'})
scraped_list = task_result.get('device_list', [])
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
count = 0
for item in scraped_list:
d_name = item.get('name')
if not d_name: continue
d_raw = item.get('raw_json', {})
target_time = item.get('target_time')
source = item.get('source', '')
# 特殊处理 106 路径
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:
target_time = f"{match.group(1).replace('_', '-')} {match.group(2).replace('_', ':')}"
except:
pass
json_str = json.dumps(d_raw, ensure_ascii=False)
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()
device.status = item.get('status')
device.current_value = item.get('value')
device.latest_time = target_time
device.check_time = now_str
device.json_data = json_str
device.offset = calculate_offset(target_time)
db.session.add(DeviceHistory(
device_id=device.id, status=device.status,
result_data=device.current_value, data_time=target_time,
json_data=json_str
))
count += 1
db.session.commit()
return jsonify({'code': 200, 'message': f'成功更新 {count} 台设备'})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/update_site', methods=['POST'])
@jwt_required()
def update_site():
if not is_manager(get_jwt_identity()): return jsonify({'code': 403}), 403
d = Device.query.filter_by(name=request.get_json().get('name')).first()
if d:
d.install_site = request.get_json().get('site')
db.session.commit()
return jsonify({'code': 200})
return jsonify({'code': 404})
@api_bp.route('/toggle_maintenance', methods=['POST'])
@jwt_required()
def toggle_maintenance():
if not is_manager(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json()
d = Device.query.filter_by(name=data.get('name')).first()
if d:
is_maintaining = data.get('is_maintaining')
d.is_maintaining = is_maintaining
# 🟢 [核心修改] 处理维修人名字
if is_maintaining:
# 开启维修:从前端获取名字 (例如 "张三") 并保存
d.maintainer = data.get('maintainer')
else:
# 结束维修:清空名字
d.maintainer = None
db.session.commit()
return jsonify({'code': 200})
return jsonify({'code': 404})
@api_bp.route('/toggle_hidden', methods=['POST'])
@jwt_required()
def toggle_hidden():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
d = Device.query.filter_by(name=request.get_json().get('name')).first()
if d:
d.is_hidden = request.get_json().get('is_hidden')
db.session.commit()
return jsonify({'code': 200})
return jsonify({'code': 404})

View File

@ -1,37 +0,0 @@
# services/core.py
import logging
import threading
from .crawler_106 import run_106_logic
from .crawler_82 import run_82_logic
task_lock = threading.Lock()
def execute_monitor_task():
"""
执行所有爬虫,返回一个大列表:
{'device_list': [item1, item2...], 'target_time': '...'}
"""
if task_lock.locked():
logging.warning(">>> 任务正在运行中,跳过")
return None
with task_lock:
logging.info(">>> 开始执行监控任务...")
# 1. 获取 106 数据列表
list_106 = run_106_logic()
# 2. 获取 82 数据列表
list_82 = run_82_logic()
# 3. 合并
combined_list = list_106 + list_82
logging.info(f">>> 任务完成,共获取 {len(combined_list)} 条数据")
return {
'device_list': combined_list,
'target_time': None, # 具体时间已在 item 里
'temp_file_path': None # 废弃旧逻辑,文件路径已在 item 里
}

8
zhandianxinxi/.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View 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
View 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
View 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
View 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>

View 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>

View File

@ -5,7 +5,7 @@
</main>
<footer class="version-footer">
2.2版本(权限管理版) © 2026 Device Monitor
2.5版本加入每日数据个数 © 2026 Device Monitor
</footer>
</div>
</template>

View File

@ -1,18 +1,30 @@
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
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')

View File

@ -1,11 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
// 1. 引入页面组件
// 1. 引入登录页面(建议新建 views/Login.vue
import Login from '../views/Login.vue'
// 2. 首页组件
import Dashboard from '../views/Dashboard.vue'
// 新增:引入用户管理页面 (确保你在 views 目录下创建了 UserManagement.vue)
import UserManagement from '../views/UserManagement.vue'
const routes = [
{
@ -20,13 +19,6 @@ const routes = [
component: Dashboard,
meta: { title: '设备监控总览', requiresAuth: true }
},
// 新增:用户管理路由
{
path: '/user-management',
name: 'UserManagement',
component: UserManagement,
meta: { title: '客户权限管理', requiresAuth: true }
},
{
path: '/data-monitor',
name: 'CrawledData',
@ -40,7 +32,7 @@ const routes = [
component: () => import('../views/MaintenanceLogs.vue'),
meta: { title: '维修日志中心', requiresAuth: true }
},
// 捕获所有未定义的路径,跳转回登录页
// 捕获所有未定义的路径,跳转回登录页或首页
{
path: '/:pathMatch(.*)*',
redirect: '/'

View File

@ -1,59 +0,0 @@
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
// 1. 创建 axios 实例
const service = axios.create({
// 根据环境自动切换前缀,开发环境走 /api生产环境可能为空
baseURL: import.meta.env.DEV ? 'http://127.0.0.1:5000' : '',
timeout: 5000 // 请求超时时间
})
// 2. 请求拦截器
service.interceptors.request.use(
config => {
// 在发送请求之前做些什么
const token = localStorage.getItem('token')
// 🛠️ 调试日志:看看发请求时到底带没带 Token
// console.log('当前请求:', config.url, '携带Token:', token)
if (token && token !== 'undefined' && token !== 'null') {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
)
// 3. 响应拦截器
service.interceptors.response.use(
response => {
return response
},
error => {
console.log('err' + error)
if (error.response) {
// 如果是 401 或 422说明 Token 无效或过期
if (error.response.status === 401 || error.response.status === 422) {
ElMessage.error('登录已过期,请重新登录')
// 清除本地缓存
localStorage.clear()
// 强制刷新页面,重置路由状态
setTimeout(() => {
window.location.href = '/'
}, 1000)
} else {
ElMessage.error(error.response.data.message || '请求错误')
}
}
return Promise.reject(error)
}
)
export default service

View File

@ -9,76 +9,42 @@
<el-tag type="info" effect="plain" round size="small">
<el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }}
</el-tag>
<el-tag :type="roleTagType" effect="dark" round size="small" style="margin-left: 10px;">
{{ roleDisplayName }}
<span v-if="currentUsername">({{ currentUsername }})</span>
</el-tag>
</div>
</div>
<div class="header-actions">
<el-button
v-if="isAdmin"
type="primary"
plain
icon="Avatar"
@click="goToUserManagement"
>
用户管理
</el-button>
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">
日志中心
</el-button>
<el-button
v-if="isAdmin"
type="warning"
plain
icon="RefreshRight"
:loading="runningTask"
@click="runManualMonitor"
>
系统检测
</el-button>
<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>
<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">离线/>7</el-tag>
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后1-7</el-tag>
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">滞后24h</el-tag>
<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" @change="fetchData" size="default">
<el-radio-button label="all">全部</el-radio-button>
<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 }})
状态异常({{ summary.errorCount + summary.warningCount }})
</el-radio-button>
<el-radio-button label="maintenance" class="blue-radio">
维修({{ summary.maintenanceCount }})
<el-radio-button label="data_error" class="yellow-radio">
数据异常({{ summary.dataErrorCount }})
</el-radio-button>
<el-radio-button v-if="isAdmin" label="hidden" class="gray-radio">
<el-radio-button label="hidden" class="gray-radio">
回收({{ summary.hiddenCount }})
</el-radio-button>
</el-radio-group>
<el-input
@ -88,6 +54,12 @@
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>
@ -95,19 +67,19 @@
:data="filteredData"
border
v-loading="loading"
style="width: 100%; min-width: 950px;"
style="width: 100%; min-width: 1250px;"
:row-class-name="tableRowClassName"
:height="tableHeight"
:default-sort="{ prop: 'sortHours', order: 'descending' }"
:default-sort="{ prop: 'sortWeight', order: 'descending' }"
>
<el-table-column label="状态" width="160" align="center" fixed="left">
<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; min-width: 60px;"
style="border:none; width: 80px;"
:style="{ color: row.statusLabelColor || '#fff' }"
>
{{ row.statusLabel }}
@ -115,7 +87,7 @@
</template>
</el-table-column>
<el-table-column label="设备名称 (点击看图)" min-width="200" show-overflow-tooltip>
<el-table-column label="设备名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div
class="device-name-wrapper"
@ -126,13 +98,33 @@
{{ 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="安装地点" min-width="160">
<el-table-column label="今日文件" width="120" align="center">
<template #default="{ row }">
<div v-if="row.isEditingSite && canManageDevice" class="editing-cell">
<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"
@ -142,26 +134,91 @@
placeholder="输入后回车"
/>
</div>
<div v-else class="display-cell" @click="canManageDevice ? handleEditSite(row) : null" :style="{ cursor: canManageDevice ? 'pointer' : 'default' }">
<span>{{ row.install_site || (canManageDevice ? '点击填写' : '-') }}</span>
<el-icon v-if="canManageDevice" class="edit-icon"><EditPen /></el-icon>
<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="220" prop="sortHours" sortable>
<el-table-column label="本月流量" width="130" prop="trafficNum" sortable>
<template #default="{ row }">
<div style="font-size: 13px;"><el-icon><Clock /></el-icon> {{ row.latest_time || '--' }}</div>
<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.status === 'offline' || row.status === '已离线'" class="status-text error-text"> 设备已离线</div>
<div v-else-if="row.diffDays > 7" class="status-text error-text"> 严重滞后 {{ Math.floor(row.diffDays) }} </div>
<div v-else-if="row.diffHours > 24" class="status-text warning-text"> 滞后 {{ Math.floor(row.diffDays) }} </div>
<div v-else-if="!row.isToday" class="status-text slight-warning-text"> 昨日数据</div>
<div v-else class="status-text success-text"> 数据最新</div>
</div>
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">
🛠 工程师介入中
<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>
@ -169,23 +226,19 @@
<template #default="{ row }">
<div class="action-group">
<template v-if="row.is_hidden">
<el-button v-if="isAdmin" type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
<el-button type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
</template>
<template v-else>
<el-switch
v-if="canManageDevice"
v-model="row.is_maintaining"
inline-prompt
active-text=""
inactive-text=""
style="--el-switch-on-color: #409EFF; margin-right: 8px;"
style="--el-switch-on-color: #409EFF;"
:before-change="() => handleMaintenanceBeforeChange(row)"
/>
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
<el-popconfirm v-if="isAdmin" title="确定隐藏?" @confirm="toggleHidden(row, true)">
<el-popconfirm title="确定隐藏?" @confirm="toggleHidden(row, true)">
<template #reference>
<el-button type="danger" link icon="Delete">隐藏</el-button>
</template>
@ -199,19 +252,44 @@
<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 request from '../utils/request'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Avatar } from '@element-plus/icons-vue'
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)
@ -221,26 +299,6 @@ const lastCheckTime = ref('')
const windowHeight = ref(window.innerHeight)
const windowWidth = ref(window.innerWidth)
// --- 🔐 权限状态管理 ---
const userRole = ref('')
const currentUsername = ref('')
const isAdmin = computed(() => userRole.value === 'admin')
const isEngineer = computed(() => userRole.value === 'engineer')
const isClient = computed(() => userRole.value === 'client')
const canManageDevice = computed(() => isAdmin.value || isEngineer.value)
const roleDisplayName = computed(() => {
if (isAdmin.value) return '超级管理员'
if (isEngineer.value) return '设备工程师'
return '客户/浏览者'
})
const roleTagType = computed(() => {
if (isAdmin.value) return 'danger'
if (isEngineer.value) return 'warning'
return 'info'
})
const tableHeight = computed(() => {
const isMobile = windowWidth.value < 768
const offset = isMobile ? 380 : 250
@ -248,298 +306,333 @@ const tableHeight = computed(() => {
})
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 summary = computed(() => {
const activeDevices = rawData.value.filter(r => !r.is_hidden)
const errors = activeDevices.filter(r => r.statusType === 'error').length
const warnings = activeDevices.filter(r => r.statusType === 'warning').length
const maintenance = activeDevices.filter(r => r.is_maintaining).length
const hidden = rawData.value.filter(r => r.is_hidden).length
return { errorCount: errors, warningCount: warnings, hiddenCount: hidden, maintenanceCount: maintenance }
})
const showAddDialog = ref(false)
const isAdding = ref(false)
const newDeviceForm = reactive({ name: '', site: '' })
const goToUserManagement = () => { router.push('/user-management') }
const handleLogout = () => {
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
localStorage.clear()
router.push('/')
ElMessage.success('已安全退出')
}).catch(() => {})
// === 辅助函数:根据中文状态返回 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 request.get('/api/devices_overview')
const res = await axios.get(`${API_BASE}/api/devices_overview`)
const backendList = res.data.data || res.data
const now = new Date()
let processedData = backendList.map(item => {
rawData.value = backendList.map(item => {
const isHidden = item.is_hidden === true || item.is_hidden === 1
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
const isBound = !!item.isBound
const isOrphanIoT = (item.source === 'iot_card')
const isWhitelist = !!item.is_whitelist
if (item.latest_time && item.latest_time !== 'N/A') {
const cleanDateStr = item.latest_time.toString().replace(/_/g, '-')
const d = new Date(cleanDateStr)
if (!isNaN(d.getTime())) {
// === 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
const diffTime = now - d
const safeDiff = diffTime > 0 ? diffTime : 0
diffHours = safeDiff / (1000 * 60 * 60)
diffDays = safeDiff / (1000 * 60 * 60 * 24)
isToday = d.toDateString() === now.toDateString()
const diff = now - d
diffHours = (diff > 0 ? diff : 0) / (1000 * 3600)
diffDays = diffHours / 24
// 🌟 核心修改点:生成标准显示格式 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}`
}
}
// 排序优先级
let sortHours = diffHours;
if (item.is_maintaining) sortHours = Number.MAX_SAFE_INTEGER;
else if (item.status === 'offline' || item.status === '已离线') sortHours = 1000000000;
else if (!validTime) sortHours = 500000000;
// 2. 解析监测数值 (保留旧逻辑)
let 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';
statusType = 'maintenance';
const mName = item.maintainer || '';
statusLabel = mName ? `维修中 (${mName})` : '维修中';
} else if ((item.status === 'offline' || item.status === '已离线') || (!validTime || diffDays > 7)) {
statusLabel = '离线/滞后'; statusColor = '#F56C6C'; statusType = 'error';
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) {
statusColor = '#E6A23C'; statusLabel = '数据滞后'; statusType = 'warning';
statusLabel = '滞后'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `滞后 ${Math.floor(diffDays)}`;
} else if (expireWarning) {
statusLabel = '即将过期'; statusColor = '#E6A23C'; statusType = 'warning';
statusReason = `即将过期`;
sortWeight = 400;
} else if (!isToday) {
statusColor = '#FAC858'; statusLabel = '昨日数据'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusLabel = '昨日数据'; statusColor = '#FAC858'; statusType = 'slight-warning'; statusLabelColor = '#333';
statusReason = '非今日数据';
} else {
sortWeight = 0;
}
return {
...item, is_hidden: isHidden, diffDays, diffHours, sortHours, isToday,
statusColor, statusLabel, statusType, statusLabelColor, isEditingSite: false, tempSite: ''
...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 // ✅ 绑定后端返回的文件数字段
}
})
processedData.sort((a, b) => b.sortHours - a.sortHours)
rawData.value = processedData
}).sort((a, b) => b.sortWeight - a.sortWeight)
lastCheckTime.value = new Date().toLocaleString()
} catch (e) {
console.error(e)
ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) }
const openLogCenter = (row) => {
if (maintenanceLogsRef.value) {
maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null)
// === 筛选逻辑 ===
const 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 runManualMonitor = async () => {
if (!isAdmin.value) return ElMessage.warning('权限不足')
runningTask.value = true
try {
const res = await request.post('/api/run_monitor')
ElMessage.success(res.data.message || '任务启动')
setTimeout(() => fetchData(), 3000)
} catch (e) {
ElMessage.warning('请求频繁或失败')
} finally {
setTimeout(() => { runningTask.value = false }, 1000)
}
}
// 🟢 筛选逻辑
const filteredData = computed(() => {
return rawData.value.filter(item => {
// 1. 如果选的是“隐藏/回收站”,只显示隐藏设备
if (filters.status === 'hidden') return item.is_hidden
// 隐藏孤儿卡
if (item.isOrphanIoT) return false
// 2. 对于其他选项,先排除隐藏设备
if (filters.status === 'hidden') return item.is_hidden
if (item.is_hidden) return false
// 3. 维修中筛选
if (filters.status === 'maintenance') return item.is_maintaining
// 4. 异常筛选 (包含离线、滞后、微滞后)
if (filters.status === 'abnormal') return (item.statusType === 'error' || item.statusType === 'warning' || item.statusType === 'slight-warning')
// 5. 全部
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 handleEditSite = (row) => {
if (!canManageDevice.value) {
ElMessage.info('您没有修改权限')
return
// === 卡池总用量 ===
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)
}
row.tempSite = row.install_site; row.isEditingSite = true
nextTick(() => {
const inputs = document.querySelectorAll('.site-input-inner input')
if (inputs.length > 0) inputs[inputs.length - 1].focus()
})
}
const saveSite = async (row) => {
if (!row.isEditingSite) return
const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false
if (oldVal === row.tempSite) return
try {
await request.post('/api/update_site', { name: row.name, site: row.tempSite })
ElMessage.success('已更新')
} catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') }
}
const handleMaintenanceBeforeChange = (row) => {
if (!canManageDevice.value) return Promise.reject()
return new Promise((resolve) => {
const newVal = !row.is_maintaining
const maintainerName = currentUsername.value || '工程师';
request.post('/api/toggle_maintenance', {
name: row.name,
is_maintaining: newVal,
maintainer: newVal ? maintainerName : null
})
.then(() => {
row.is_maintaining = newVal;
row.maintainer = newVal ? maintainerName : null;
if (newVal) {
row.statusColor = '#409EFF';
row.statusLabel = `维修中 (${maintainerName})`;
row.statusType = 'maintenance';
} else {
row.statusLabel = '更新中...';
}
ElMessage.success(newVal ? '已进入维修模式' : '已恢复');
fetchData();
resolve(true)
})
.catch(() => {
ElMessage.error('操作失败');
resolve(false)
})
})
}
const toggleHidden = async (row, targetState) => {
if (!isAdmin.value) return
try {
await request.post('/api/toggle_hidden', { name: row.name, is_hidden: targetState })
row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复')
} catch (e) { ElMessage.error('操作失败') }
}
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.statusType === 'maintenance') return 'maintenance-row'
if (row.data_quality === 'error') return 'data-error-row' // 优先显示数值严重错误
if (row.statusType === 'error') return 'error-row'
if (row.data_quality === 'warning') return 'data-warning-row' // 数值警告
if (row.statusType === 'warning') return 'warning-row'
if (row.statusType === 'maintenance') return 'maintenance-row'
return ''
}
const updateDimensions = () => {
windowHeight.value = window.innerHeight
windowWidth.value = window.innerWidth
}
onMounted(() => {
userRole.value = localStorage.getItem('role') || 'client'
currentUsername.value = localStorage.getItem('username') || ''
fetchData()
window.addEventListener('resize', updateDimensions)
})
const updateDimensions = () => { windowHeight.value = window.innerHeight; windowWidth.value = window.innerWidth }
onMounted(() => { fetchData(); window.addEventListener('resize', updateDimensions) })
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
</script>
<style scoped>
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
.main-card { border-radius: 8px; overflow: visible; }
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.sys-title { margin: 0; font-size: 20px; color: #303133; font-weight: 700; white-space: nowrap; }
.left-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.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; transition: width 0.3s; }
/* 🟢 自定义筛选按钮样式,增加辨识度 */
.red-radio :deep(.el-radio-button__inner) { color: #F56C6C; }
.red-radio.is-active :deep(.el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; color: #fff; box-shadow: -1px 0 0 0 #F56C6C; }
.blue-radio :deep(.el-radio-button__inner) { color: #409EFF; }
.blue-radio.is-active :deep(.el-radio-button__inner) { background-color: #409EFF; border-color: #409EFF; color: #fff; box-shadow: -1px 0 0 0 #409EFF; }
.gray-radio :deep(.el-radio-button__inner) { color: #909399; }
.gray-radio.is-active :deep(.el-radio-button__inner) { background-color: #909399; border-color: #909399; color: #fff; box-shadow: -1px 0 0 0 #909399; }
.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-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
.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; }
.maintenance-text { color: #409EFF; font-weight: bold; }
.display-cell { padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
.edit-icon { color: #409EFF; margin-left: 5px; }
.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 { width: 100%; justify-content: space-between; margin-bottom: 5px; }
.sys-title { font-size: 18px; }
.header-actions { width: 100%; justify-content: space-between; }
.header-actions .el-button { flex: 1; margin-left: 5px !important; margin-right: 5px !important; }
.divider-mobile { width: 1px; height: 20px; background: #dcdfe6; margin: 0 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; }
:deep(.el-radio-button__inner) { width: 100%; padding: 8px 0; }
.search-input { width: 100%; margin-top: 5px; }
.el-button [class*="el-icon"] + span { display: inline-block; }
.total-usage-tag { width: 100%; justify-content: center; margin: 5px 0 0 0; }
}
</style>
</style>

View File

@ -47,14 +47,7 @@
<el-empty
v-if="!loading && chartModules.length === 0"
:description="emptyText"
>
<template #default>
<div>{{ emptyText }}</div>
<div style="font-size: 12px; color: #999; margin-top: 5px;" v-if="emptyText.includes('解析失败')">
(请按 F12 查看控制台 Console 日志以排查数据格式)
</div>
</template>
</el-empty>
/>
<div v-else class="charts-scroll-container">
<div
@ -72,31 +65,29 @@
<script setup>
import { ref, nextTick, onBeforeUnmount } from 'vue'
import request from '../utils/request' // 确保 request 工具路径正确
import axios from 'axios'
import * as echarts from 'echarts'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
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('')
const selectedDate = ref('')
const dataTimestamp = 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 实例管理 ---
// ECharts 实例管理
let chartInstances = []
const chartRefs = ref([])
const setChartRef = (el, index) => { if (el) chartRefs.value[index] = el }
// 动态 Ref 设置函数
const setChartRef = (el, index) => {
if (el) chartRefs.value[index] = el
}
// 日期禁用逻辑
// 禁止选择未来日期
const disabledDate = (time) => {
return time.getTime() > Date.now()
}
@ -104,7 +95,7 @@ const disabledDate = (time) => {
// 格式化设备名称
const formatDisplayName = (name) => (name ? name.toUpperCase().replace(/_/g, ' ') : '')
// 获取今天日期字符串 YYYY-MM-DD
// 辅助函数:获取今天日期字符串
const getTodayString = () => {
const today = new Date()
const y = today.getFullYear()
@ -113,16 +104,21 @@ const getTodayString = () => {
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
@ -131,71 +127,62 @@ const open = (row) => {
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()
disposeCharts() // 销毁旧图表实例
try {
const res = await request.get('/api/device_data_by_date', {
// 发起请求:根据设备名和日期获取数据
const res = await axios.get(`${API_BASE}/api/device_data_by_date`, {
params: {
name: deviceName.value,
date: selectedDate.value
}
})
// 1. 获取原始数据
const rawContent = res.data.content
const source = res.data.source
// 2. 关键修复:安全转换。如果 content 是 null/undefined转为空字符串如果是对象转为字符串。
let safeContent = ''
if (rawContent !== null && rawContent !== undefined) {
safeContent = typeof rawContent === 'object' ? JSON.stringify(rawContent) : String(rawContent)
}
const { content, source } = res.data
// [关键容错] 优先使用接口返回的 source若接口未返回则使用列表页传来的 source
// 这决定了是使用 106正则解析 还是 82JSON解析
const effectiveSource = source || currentSource.value
// Debug日志
console.log(`[LoadData] Source: ${effectiveSource}, Safe Content Length: ${safeContent.length}`)
// 3. 判空逻辑
// 注意:有时候后端返回字符串 "null" 或 "{}" 也代表空
if (!safeContent || safeContent === 'null' || safeContent === '{}' || safeContent === '""') {
if (!content || content === '{}' || content === 'null') {
emptyText.value = `${selectedDate.value} 无数据记录`
} else {
// 4. 解析数据
// 解析数据
const modules = parseChartData({
name: deviceName.value,
content: safeContent, // 传入处理后的安全字符串
content,
source: effectiveSource
})
chartModules.value = modules
if (modules.length === 0) {
// 安全截取字符串,避免报错
console.warn('解析结果为空。原始内容片段:', safeContent.substring(0, 100))
emptyText.value = '数据解析失败 (格式不匹配)'
} else {
// 等待 DOM 更新后渲染图表
await nextTick()
initCharts()
}
@ -205,41 +192,36 @@ const loadData = async () => {
emptyText.value = `${selectedDate.value} 无数据记录`
} else {
console.error('Data Load Error:', e)
emptyText.value = '数据加载异常'
ElMessage.error('获取详细数据失败')
emptyText.value = '请求出错'
}
} finally {
loading.value = false
}
}
// --- 解析器106 系列 ---
// --- 数据解析逻辑 ---
// 1. 解析 106 类型数据 (正则解析)
function parse106Data(content) {
if (typeof content !== 'string') return []
const modules = []
// 宽松正则:不强制开头,允许空格,允许跨行匹配
const blockRegex = /(?:FS\d_Info|Info)?[\s\S]*?Model\s*,\s*([^,\r\n]+)[\s\S]*?SN\s*,\s*([^,\r\n]+)[\s\S]*?Wavelength\s*,\s*([0-9\.,\s]+)/gi
// 匹配 Model, SN 和 波长信息
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let match
blockRegex.lastIndex = 0
while ((match = blockRegex.exec(content)) !== null) {
const modelRaw = match[1].trim()
const snRaw = match[2].trim()
const waveRaw = match[3]
const wavelengths = waveRaw.split(',').map(Number).filter((n) => !isNaN(n))
if (wavelengths.length === 0) continue
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++) {
// 转义 Model 名称中的特殊字符
const escapedModel = modelRaw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pRegex = new RegExp(`${escapedModel}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
const dMatch = content.match(pRegex)
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))) {
@ -251,19 +233,18 @@ function parse106Data(content) {
}
}
}
if (series.length) {
modules.push({ type: '106', model: modelRaw, sn: snRaw, xAxis: wavelengths, series })
modules.push({ type: '106', model, sn, xAxis: wavelengths, series })
}
}
return modules
}
// --- 解析器:82 系列 ---
// 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 [{
@ -283,25 +264,20 @@ function parse82Data(content, deviceName) {
}
}
// --- 主解析入口 ---
// 3. 统一解析入口
function parseChartData(device) {
if (!device || !device.content) return []
const is106Site = device.source && device.source.includes('106')
// 这里的 content 已经是经过 loadData 转换的安全字符串了
const contentStr = device.content.trim()
const is106Source = (device.source && device.source.includes('106'))
// 判断是否像 106 文本 (不以{开头 且 包含Model关键字)
const looksLike106Text = !contentStr.startsWith('{') && /Model/i.test(contentStr)
if (is106Source || looksLike106Text) {
return parse106Data(contentStr)
if (is106Site) {
return parse106Data(device.content)
} else {
return parse82Data(contentStr, device.name)
return parse82Data(device.content, device.name)
}
}
// --- ECharts 配置 ---
// --- ECharts 渲染逻辑 ---
function getChartOption(moduleData, isMobile = false) {
const titleText = moduleData.type === '106'
? `Model: ${moduleData.model} (SN: ${moduleData.sn})`
@ -314,60 +290,27 @@ function getChartOption(moduleData, isMobile = false) {
top: 10,
textStyle: { fontSize: isMobile ? 14 : 16 },
},
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'cross' }
},
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
},
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 },
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 },
})),
}
}
// --- ECharts 初始化 ---
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) {
const oldInstance = echarts.getInstanceByDom(el)
if (oldInstance) oldInstance.dispose()
// 防止重复初始化
if (echarts.getInstanceByDom(el)) echarts.getInstanceByDom(el).dispose()
const chart = echarts.init(el)
chart.setOption(getChartOption(mod, isMobile))
chartInstances.push(chart)
@ -375,7 +318,6 @@ const initCharts = () => {
})
}
// --- ECharts 销毁 ---
const disposeCharts = () => {
chartInstances.forEach((chart) => chart && chart.dispose())
chartInstances = []

View 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>

View 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>

View File

@ -9,6 +9,7 @@
append-to-body
>
<div class="logs-container">
<div class="toolbar">
<div class="filter-group">
<el-date-picker
@ -22,24 +23,21 @@
@change="fetchLogs"
style="width: 260px"
/>
<el-input
v-model="keyword"
placeholder="搜索:设备名 / 工程师 / 内容"
style="width: 300px"
:disabled="isSearchLocked"
:clearable="!isSearchLocked"
clearable
@clear="fetchLogs"
@keyup.enter="fetchLogs"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="fetchLogs" :disabled="isSearchLocked">查询</el-button>
<el-button type="primary" @click="fetchLogs">查询</el-button>
</div>
<div class="action-group" v-if="userRole !== 'client'">
<el-button type="success" icon="Plus" @click="() => openAddDialog()">新增记录</el-button>
<div class="action-group">
<el-button type="success" icon="Plus" @click="openAddDialog">新增记录</el-button>
</div>
</div>
@ -60,31 +58,22 @@
</el-table-column>
<el-table-column prop="location" label="地点" width="150" show-overflow-tooltip />
<el-table-column prop="engineer" label="工程师" width="120">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.engineer }}</el-tag>
</template>
</el-table-column>
<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" v-if="userRole !== 'client'">
<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
v-if="userRole === 'admin'"
title="确定删除这条记录吗?"
@confirm="deleteLog(row.id)"
>
<el-popconfirm title="确定删除这条记录吗?" @confirm="deleteLog(row.id)">
<template #reference>
<el-button type="danger" link icon="Delete">删除</el-button>
</template>
@ -103,34 +92,20 @@
<el-form :model="logDialog.form" label-width="80px" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备名称" required>
<el-autocomplete
<el-form-item label="设备名称">
<el-input
v-model="logDialog.form.device_name"
:fetch-suggestions="querySearchDevice"
placeholder="必须选择现有设备"
:disabled="logDialog.isDeviceLocked || logDialog.isEdit"
style="width: 100%"
clearable
highlight-first-item
trigger-on-focus
>
<template #default="{ item }">
<span class="name">{{ formatDisplayName(item.value) }}</span>
</template>
</el-autocomplete>
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="工程师" required>
<el-input
v-model="logDialog.form.engineer"
:placeholder="userRole === 'engineer' ? '' : '请输入工程师姓名'"
:disabled="userRole === 'engineer'"
>
<template #prefix>
<el-icon><UserFilled /></el-icon>
</template>
</el-input>
<el-form-item label="工程师">
<el-input v-model="logDialog.form.engineer" placeholder="例: 张工" />
</el-form-item>
</el-col>
</el-row>
@ -139,7 +114,7 @@
<el-input v-model="logDialog.form.location" placeholder="例: 3号楼顶层" />
</el-form-item>
<el-form-item label="事件内容" required>
<el-form-item label="事件内容">
<el-input
v-model="logDialog.form.content"
type="textarea"
@ -161,31 +136,26 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import request from '../utils/request'
import { ref, reactive } from 'vue'
import axios from 'axios'
import { ElMessage, ElConfigProvider } from 'element-plus'
import { Search, Plus, Delete, Edit, UserFilled } from '@element-plus/icons-vue'
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 isSearchLocked = ref(false)
// 缓存的所有设备列表,格式 [{value: 'dev1'}, {value: 'dev2'}]
const allDevices = ref([])
const userRole = ref('')
const currentUsername = ref('')
// 弹窗状态封装
const logDialog = reactive({
visible: false,
submitting: false,
isEdit: false,
isDeviceLocked: false,
form: {
id: null,
device_name: '',
@ -195,59 +165,19 @@ const logDialog = reactive({
}
})
// 统一获取认证信息
const refreshAuth = () => {
userRole.value = localStorage.getItem('role') || 'client'
currentUsername.value = localStorage.getItem('username') || ''
}
// --- 方法逻辑 ---
onMounted(() => { refreshAuth() })
// 1. 打开主列表
// 1. 暴露给父组件的打开方法
const open = (prefillData = null) => {
refreshAuth()
visible.value = true
isSearchLocked.value = false
keyword.value = ''
// 🟢 必须:打开时立即加载设备库,否则无法校验
fetchAllDevices()
// 如果从设备卡片点击进来,自动筛选该设备
if (prefillData && prefillData.deviceName) {
keyword.value = prefillData.deviceName
if (userRole.value !== 'admin') {
isSearchLocked.value = true
}
}
fetchLogs()
}
// 🟢 获取设备库(核心)
const fetchAllDevices = async () => {
try {
const res = await request.get('/api/devices_overview')
if (res.data && res.data.data) {
allDevices.value = res.data.data.map(d => ({ value: d.name }))
}
} catch (e) {
console.error('无法加载设备列表', e)
}
}
// 自动补全过滤
const querySearchDevice = (queryString, cb) => {
const results = queryString
? allDevices.value.filter(createFilter(queryString))
: allDevices.value
cb(results)
}
const createFilter = (queryString) => {
return (item) => {
return (item.value.toLowerCase().indexOf(queryString.toLowerCase()) !== -1)
}
}
// 2. 获取日志列表
// 2. 获取数据列表
const fetchLogs = async () => {
loading.value = true
try {
@ -256,140 +186,118 @@ const fetchLogs = async () => {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res = await request.get('/api/logs/list', { params })
const res = await axios.get(`${API_BASE}/api/logs/list`, { params })
logsList.value = res.data.data
} catch (e) {
ElMessage.error('获取日志失败')
ElMessage.error('加载日志中心数据失败')
} finally {
loading.value = false
}
}
// 3. 打开新增
// 3. 处理新增
const openAddDialog = () => {
refreshAuth()
logDialog.isEdit = false
let autoEngineer = ''
if (userRole.value === 'engineer') {
autoEngineer = currentUsername.value
}
// 逻辑:如果搜索栏锁定了(从设备卡片进来的),直接锁定设备名
let prefillDevice = ''
let lockDevice = false
if (isSearchLocked.value && keyword.value) {
prefillDevice = keyword.value
lockDevice = true
}
logDialog.isDeviceLocked = lockDevice
logDialog.form = {
id: null,
device_name: prefillDevice,
engineer: autoEngineer,
// 自动带入当前的搜索词作为设备名,提高录入效率
device_name: keyword.value || '',
engineer: '',
location: '',
content: ''
}
logDialog.visible = true
}
// 4. 编辑
// 4. 处理修改
const openEditDialog = (row) => {
refreshAuth()
logDialog.isEdit = true
// 编辑模式禁止修改设备名(防止关联错误)
logDialog.isDeviceLocked = true
logDialog.form = {
id: row.id,
device_name: row.device_name,
engineer: userRole.value === 'engineer' ? currentUsername.value : row.engineer,
engineer: row.engineer,
location: row.location,
content: row.content
}
logDialog.visible = true
}
// 5. 🟢 提交保存 (核心修改区)
// 5. 提交表单(核心逻辑区分)
const submitLog = async () => {
refreshAuth()
const inputDeviceName = logDialog.form.device_name;
// A. 基础非空校验
if (!inputDeviceName || !logDialog.form.content) {
return ElMessage.warning('请填写 设备名称 和 事件内容')
}
// B. 🔴 关键逻辑:校验设备名是否存在于设备库中
// 如果当前设备列表还没加载完(极少情况),尝试重新加载一次或者报错
if (allDevices.value.length === 0) {
await fetchAllDevices();
}
// 检查输入的设备名是否能在 allDevices 数组里找到 exact match
const isDeviceExist = allDevices.value.some(d => d.value === inputDeviceName);
// 如果设备名不在库中,且也不是空,直接拒绝
if (!isDeviceExist) {
return ElMessage.error(`设备 "${inputDeviceName}" 不存在!请从下拉列表中选择有效的设备。`);
}
// C. 身份校验
if (userRole.value !== 'engineer' && !logDialog.form.engineer) {
return ElMessage.warning('请填写工程师姓名')
}
if (userRole.value === 'engineer') {
logDialog.form.engineer = currentUsername.value
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 request.post(endpoint, logDialog.form)
await axios.post(`${API_BASE}${endpoint}`, logDialog.form)
ElMessage.success(logDialog.isEdit ? '记录已更新' : '记录已添加')
ElMessage.success(logDialog.isEdit ? '日志已成功修改' : '日志已添加')
logDialog.visible = false
fetchLogs()
fetchLogs() // 刷新列表
} catch (e) {
ElMessage.error(e.response?.data?.msg || '保存失败')
ElMessage.error('操作失败,请检查网络或后端服务')
} finally {
logDialog.submitting = false
}
}
// 6. 删除
// 6. 删除逻辑
const deleteLog = async (id) => {
try {
await request.post('/api/logs/delete', { id })
ElMessage.success('删除')
await axios.post(`${API_BASE}/api/logs/delete`, { id })
ElMessage.success('记录已安全删除')
fetchLogs()
} catch (e) {
ElMessage.error('无权删除')
ElMessage.error('删除操作失败')
}
}
const formatDisplayName = (n) => n ? n.toUpperCase().replace(/_/g, ' ') : ''
// 格式化名称工具
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; }
.filter-group { display: flex; gap: 12px; }
.logs-container {
padding: 10px;
}
.name {
font-weight: bold;
color: #333;
.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: #303133 !important;
-webkit-text-fill-color: #303133 !important;
font-weight: bold;
color: #606266;
-webkit-text-fill-color: #606266;
}
</style>

View File

@ -1,481 +0,0 @@
<template>
<div class="user-manage-container">
<el-card shadow="never" class="main-card">
<template #header>
<div class="header-row">
<div class="left-panel">
<h2 class="sys-title">👥 用户与权限管理</h2>
</div>
<div class="header-actions">
<el-button @click="router.push('/dashboard')" icon="Back">返回监控</el-button>
<el-button type="primary" icon="Plus" @click="openCreateModal">新建用户</el-button>
</div>
</div>
</template>
<el-table :data="users" border style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="username" label="用户名" min-width="150">
<template #default="{ row }">
<span style="font-weight: bold;">{{ row.username }}</span>
</template>
</el-table-column>
<el-table-column prop="role" label="角色身份" width="150" align="center">
<template #default="{ row }">
<el-tag v-if="row.role === 'admin'" type="danger" effect="dark">超级管理员</el-tag>
<el-tag v-else-if="row.role === 'engineer'" type="warning" effect="dark">设备工程师</el-tag>
<el-tag v-else type="info" effect="plain">普通客户</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="180">
<template #default="{ row }">
{{ new Date(row.created_at).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="关联设备数" min-width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.role === 'admin'" type="danger" effect="plain">全部权限</el-tag>
<el-tag v-else effect="plain" type="success">{{ row.allowed_device_ids?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.role !== 'admin'"
type="primary"
link
icon="Setting"
@click="openPermissionModal(row)"
>
分配设备
</el-button>
<el-popconfirm
title="确定删除该用户吗?"
confirm-button-text="删除"
cancel-button-text="取消"
icon="Warning"
icon-color="red"
@confirm="deleteUser(row.id)"
>
<template #reference>
<el-button type="danger" link icon="Delete">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showCreateModal" title="新建账号" width="400px">
<el-form :model="newUser" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="newUser.username" placeholder="请输入登录名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="newUser.password" type="password" placeholder="设置初始密码" show-password />
</el-form-item>
<el-form-item label="角色权限">
<el-select v-model="newUser.role" placeholder="请选择角色" style="width: 100%">
<el-option label="普通客户 (只读)" value="client" />
<el-option label="设备工程师 (可维护)" value="engineer" />
<el-option label="超级管理员 (Root权限)" value="admin" />
</el-select>
<div style="font-size: 12px; color: #999; margin-top: 5px; line-height: 1.2;">
<span v-if="newUser.role === 'admin'" style="color: #f56c6c;">* 拥有删除用户爬虫控制等最高权限</span>
<span v-else-if="newUser.role === 'engineer'" style="color: #e6a23c;">* 拥有修改设备地点写日志权限</span>
<span v-else>* 仅可查看被分配的设备数据</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showCreateModal = false">取消</el-button>
<el-button type="primary" @click="createUser" :loading="creating">确认创建</el-button>
</span>
</template>
</el-dialog>
<el-dialog
v-model="showPermissionModal"
:title="`分配设备 - [${currentUser?.username}]`"
width="720px"
top="5vh"
destroy-on-close
>
<div class="permission-wrapper">
<div class="selection-toolbar">
<el-input
v-model="deviceFilterKeyword"
placeholder="搜索设备名称或地点..."
prefix-icon="Search"
clearable
style="width: 240px"
/>
<div class="toolbar-stats">
<span>已选: <span class="highlight-count">{{ selectedDeviceIds.length }}</span> / {{ allDevices.length }}</span>
<el-divider direction="vertical" />
<el-checkbox v-model="showSelectedOnly" label="只看已选" size="small" />
</div>
<div class="toolbar-actions">
<el-button link type="primary" @click="selectAllDevices">全选</el-button>
<el-button link type="info" @click="clearAllDevices">清空</el-button>
</div>
</div>
<div class="device-grid-container">
<el-scrollbar max-height="450px">
<div class="device-grid">
<div
v-for="device in displayDevices"
:key="device.id"
class="device-card"
:class="{ 'is-active': selectedDeviceIds.includes(device.id) }"
@click="toggleDeviceSelection(device.id)"
>
<div class="card-content">
<div class="d-name">{{ device.name }}</div>
<div class="d-site">
<el-icon><Location /></el-icon> {{ device.install_site || '未分配地点' }}
</div>
</div>
<div class="check-mark" v-if="selectedDeviceIds.includes(device.id)">
<el-icon><Check /></el-icon>
</div>
</div>
<div v-if="displayDevices.length === 0" class="empty-tip">
<el-empty description="没有找到匹配的设备" :image-size="80" />
</div>
</div>
</el-scrollbar>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showPermissionModal = false">取消</el-button>
<el-button type="primary" @click="savePermissions">保存授权 ({{ selectedDeviceIds.length }})</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import request from '../utils/request'
import { ElMessage } from 'element-plus'
import { Back, Plus, Setting, Delete, Warning, Search, Location, Check } from '@element-plus/icons-vue'
const router = useRouter()
const loading = ref(false)
const creating = ref(false)
const users = ref([])
const rawDevices = ref([]) // 原始设备数据
const showCreateModal = ref(false)
const showPermissionModal = ref(false)
const newUser = ref({ username: '', password: '', role: 'client' })
const currentUser = ref(null)
// 🟢 新增/修改的状态变量
const selectedDeviceIds = ref([]) // 存储当前选中的ID数组
const deviceFilterKeyword = ref('') // 搜索关键词
const showSelectedOnly = ref(false) // 是否只看已选
// 统一设备列表数据源
const allDevices = computed(() => rawDevices.value)
// 🟢 核心计算逻辑:过滤设备列表
const displayDevices = computed(() => {
let list = allDevices.value
// 1. 关键词过滤
if (deviceFilterKeyword.value) {
const k = deviceFilterKeyword.value.toLowerCase()
list = list.filter(d =>
(d.name && d.name.toLowerCase().includes(k)) ||
(d.install_site && d.install_site.toLowerCase().includes(k))
)
}
// 2. "只看已选"过滤
if (showSelectedOnly.value) {
list = list.filter(d => selectedDeviceIds.value.includes(d.id))
}
return list
})
onMounted(async () => {
await fetchUsers()
await fetchAllDevices()
})
const fetchUsers = async () => {
loading.value = true
try {
const res = await request.get('/api/admin/users')
users.value = res.data.data || res.data
} catch (e) { console.error(e) } finally { loading.value = false }
}
const fetchAllDevices = async () => {
try {
const res = await request.get('/api/devices_overview')
rawDevices.value = res.data.data || res.data
} catch (e) { console.error(e) }
}
const openCreateModal = () => {
newUser.value = {username: '', password: '', role: 'client'}
showCreateModal.value = true
}
const createUser = async () => {
if (!newUser.value.username || !newUser.value.password) return ElMessage.warning('请填写完整')
creating.value = true
try {
await request.post('/api/admin/create_user', newUser.value)
ElMessage.success('用户创建成功')
showCreateModal.value = false
fetchUsers()
} catch (e) {
ElMessage.error(e.response?.data?.msg || '创建失败')
} finally {
creating.value = false
}
}
// 🟢 打开分配弹窗
const openPermissionModal = (user) => {
currentUser.value = user
// 确保是新数组,避免引用污染
selectedDeviceIds.value = user.allowed_device_ids ? [...user.allowed_device_ids] : []
// 重置筛选状态
deviceFilterKeyword.value = ''
showSelectedOnly.value = false
showPermissionModal.value = true
}
// 🟢 切换单个选中状态
const toggleDeviceSelection = (id) => {
const index = selectedDeviceIds.value.indexOf(id)
if (index > -1) {
selectedDeviceIds.value.splice(index, 1)
} else {
selectedDeviceIds.value.push(id)
}
}
// 🟢 全选(基于当前过滤后的列表)
const selectAllDevices = () => {
const currentIds = displayDevices.value.map(d => d.id)
// 将未选中的添加进去Set去重
const newSet = new Set([...selectedDeviceIds.value, ...currentIds])
selectedDeviceIds.value = Array.from(newSet)
}
// 🟢 清空(全部清空)
const clearAllDevices = () => {
selectedDeviceIds.value = []
}
const savePermissions = async () => {
try {
await request.post('/api/admin/assign_devices', {
user_id: currentUser.value.id,
device_ids: selectedDeviceIds.value
})
ElMessage.success('权限已更新')
showPermissionModal.value = false
fetchUsers()
} catch (e) {
ElMessage.error('保存失败')
}
}
const deleteUser = async (id) => {
try {
await request.post('/api/admin/delete_user', {user_id: id})
ElMessage.success('用户已删除')
fetchUsers()
} catch (e) {
ElMessage.error(e.response?.data?.msg || '删除失败')
}
}
</script>
<style scoped>
.user-manage-container {
padding: 10px;
background: #f5f7fa;
min-height: 100vh;
box-sizing: border-box;
}
.main-card {
border-radius: 8px;
min-height: 80vh;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.sys-title {
margin: 0;
font-size: 20px;
color: #303133;
font-weight: 700;
}
.header-actions {
display: flex;
gap: 10px;
}
/* 🟢 新增:权限选择器样式 */
.permission-wrapper {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
}
.selection-toolbar {
padding: 10px 15px;
background: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.toolbar-stats {
font-size: 13px;
color: #606266;
display: flex;
align-items: center;
gap: 10px;
}
.highlight-count {
color: #409EFF;
font-weight: bold;
font-size: 15px;
}
.toolbar-actions {
margin-left: auto;
}
.device-grid-container {
padding: 15px;
background: #fff;
}
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.device-card {
position: relative;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
}
.device-card:hover {
border-color: #c6e2ff;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.device-card.is-active {
border-color: #409EFF;
background-color: #ecf5ff;
}
.card-content {
pointer-events: none; /* 让点击穿透到底层div */
}
.d-name {
font-weight: bold;
color: #303133;
font-size: 14px;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d-site {
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 4px;
}
/* 右下角打钩图标 */
.check-mark {
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 28px 28px 0;
border-color: transparent #409EFF transparent transparent;
}
.check-mark .el-icon {
position: absolute;
top: 2px;
right: -26px;
color: #fff;
font-size: 12px;
font-weight: bold;
}
.empty-tip {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
}
/* 移动端适配 */
@media screen and (max-width: 768px) {
.selection-toolbar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.toolbar-actions {
margin-left: 0;
display: flex;
justify-content: flex-end;
}
.device-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -4,24 +4,12 @@
<h2>🚀 设备监控系统登录</h2>
<el-form :model="loginForm" @keyup.enter="handleLogin">
<el-form-item>
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
/>
<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-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-button type="primary" :loading="loading" style="width: 100%" @click="handleLogin">登录</el-button>
</el-form>
</el-card>
</div>
@ -31,8 +19,7 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue' // 确保引入了图标
import request from '../utils/request'
import axios from 'axios'
const router = useRouter()
const loading = ref(false)
@ -45,37 +32,16 @@ const handleLogin = async () => {
loading.value = true
try {
const res = await request.post('/api/login', loginForm.value)
const data = res.data
// 兼容部分后端可能不返回 code 的情况,默认为 200
const code = data.code !== undefined ? data.code : 200
if (code === 200) {
if (!data.token) {
ElMessage.error('登录异常:服务器未返回 Token')
return
}
console.log('登录成功:', data)
// === 💾 核心修改:保存所有必要信息 ===
const res = await axios.post('/api/login', loginForm.value)
if (res.data.code === 200) {
// 存储登录状态
localStorage.setItem('isLoggedIn', 'true')
localStorage.setItem('token', data.token)
localStorage.setItem('role', data.role || 'client')
localStorage.setItem('user_id', data.user_id || '')
// ✅ 关键保存用户名Dashboard 才能获取到
localStorage.setItem('username', data.username || loginForm.value.username)
localStorage.setItem('token', res.data.token)
ElMessage.success('欢迎回来')
router.push('/dashboard')
} else {
ElMessage.error(data.message || '登录失败')
router.push('/dashboard') // 登录成功跳转
}
} catch (error) {
console.error(error)
// request.js 通常会处理网络错误,这里主要处理业务逻辑错误
ElMessage.error(error.response?.data?.message || '登录失败')
} finally {
loading.value = false
}
@ -92,13 +58,8 @@ const handleLogin = async () => {
}
.login-card {
width: 400px;
padding: 40px 20px;
padding: 20px;
text-align: center;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
h2 {
margin-bottom: 30px;
color: #303133;
}
</style>