Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0080cecb3 | |||
| 6735ad3a93 | |||
| 94ff1ddf57 | |||
| ca03816668 | |||
| c416c8ad07 | |||
| 9c73e25937 | |||
| 43f049112f | |||
| ffbd494b7b | |||
| ca895af384 | |||
| e67edec876 | |||
| 4f970967e9 | |||
| f527faa06e | |||
| 29b48f6ba4 | |||
| a8984a156c | |||
| a5b0b71d26 | |||
| af4b4a28c3 | |||
| 3099427eb6 | |||
| ef440177b3 | |||
| 15d66d6694 | |||
| cbe6e884b5 | |||
| fa66da3ff5 | |||
| 2c2f9e43e3 | |||
| 45c3d602c0 | |||
| e36b68da2e | |||
| 19e34ec065 | |||
| e9c9c60b27 | |||
| db2c040e5b |
12
.idea/ZDXX.iml
generated
Normal file
12
.idea/ZDXX.iml
generated
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.11 (Learn-Web-spider)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
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()
|
|
||||||
Binary file not shown.
313
1.1/test1.py
313
1.1/test1.py
@ -1,313 +0,0 @@
|
|||||||
import os
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
import requests
|
|
||||||
from datetime import datetime
|
|
||||||
from flask import Flask, jsonify
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
|
||||||
from flask_cors import CORS
|
|
||||||
from lxml import etree
|
|
||||||
|
|
||||||
# --- 初始化 Flask App ---
|
|
||||||
app = Flask(__name__)
|
|
||||||
CORS(app) # 解决前端 Vite 5173 端口的跨域问题
|
|
||||||
|
|
||||||
# --- 数据库配置 (SQLite) ---
|
|
||||||
# 数据库文件将生成在 backend 目录下
|
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///monitor_data.db'
|
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
||||||
db = SQLAlchemy(app)
|
|
||||||
|
|
||||||
|
|
||||||
# --- 数据库模型 ---
|
|
||||||
class ErrorLog(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
source = db.Column(db.String(50)) # 106网站 / 82网站
|
|
||||||
name = db.Column(db.String(100)) # 设备名称/站点ID
|
|
||||||
reason = db.Column(db.String(255)) # 状态描述
|
|
||||||
offset = db.Column(db.String(50)) # 日期偏移 (如: 滞后 2 天)
|
|
||||||
latest_time = db.Column(db.String(50)) # 最新文件日期
|
|
||||||
check_time = db.Column(db.String(50)) # 本次检查时间
|
|
||||||
content = db.Column(db.Text, nullable=True) # 专门存储 Tower_ 站点的 JSON 内容
|
|
||||||
|
|
||||||
|
|
||||||
# 每次启动时确保表结构已建立
|
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
# --- 基础配置 ---
|
|
||||||
DATA_ROOT = "data"
|
|
||||||
FRPS_DIR = os.path.join(DATA_ROOT, "frps_106")
|
|
||||||
WEATHER_DIR = os.path.join(DATA_ROOT, "weather_82")
|
|
||||||
|
|
||||||
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'}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is_running = False # 全局任务状态锁
|
|
||||||
|
|
||||||
|
|
||||||
# --- 通用工具函数 ---
|
|
||||||
|
|
||||||
def add_error_to_db(source, name, reason, latest_time="N/A", content=None):
|
|
||||||
"""计算日期偏移并记录到数据库"""
|
|
||||||
days_diff = "N/A"
|
|
||||||
if latest_time and latest_time != "N/A":
|
|
||||||
try:
|
|
||||||
# 兼容 2024_01_01 和 2024-01-01 格式
|
|
||||||
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 = "解析失败"
|
|
||||||
|
|
||||||
log = ErrorLog(
|
|
||||||
source=source,
|
|
||||||
name=name,
|
|
||||||
reason=reason,
|
|
||||||
offset=days_diff,
|
|
||||||
latest_time=latest_time,
|
|
||||||
check_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
content=content
|
|
||||||
)
|
|
||||||
db.session.add(log)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# --- 106 业务逻辑 ---
|
|
||||||
|
|
||||||
def get_106_dynamic_token(port):
|
|
||||||
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():
|
|
||||||
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_to_db("106网站", "主入口API", f"访问失败: HTTP {resp.status_code}")
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in resp.json().get('proxies', []):
|
|
||||||
name = item.get('name', 'Unknown')
|
|
||||||
if not name.lower().endswith('_data'): continue
|
|
||||||
|
|
||||||
status = str(item.get('status', '')).lower().strip()
|
|
||||||
if status != 'online':
|
|
||||||
add_error_to_db("106网站", name, f"设备离线 ({status})")
|
|
||||||
continue
|
|
||||||
|
|
||||||
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:
|
|
||||||
port = item.get('conf', {}).get('remote_port')
|
|
||||||
token = get_106_dynamic_token(port)
|
|
||||||
if not token:
|
|
||||||
add_error_to_db("106网站", name, "Token获取失败")
|
|
||||||
continue
|
|
||||||
|
|
||||||
headers = {"Authorization": c["primary_auth"], "x-auth": token, "User-Agent": "Mozilla/5.0"}
|
|
||||||
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)
|
|
||||||
best_date = find_closest_item(res2.json().get('items', []), is_date_level=True)
|
|
||||||
|
|
||||||
if not best_date or best_date[2] != today_str:
|
|
||||||
add_error_to_db("106网站", name, "未找到今日文件夹", best_date[2] if best_date else "N/A")
|
|
||||||
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)
|
|
||||||
best_file = find_closest_item(res3.json().get('items', []), is_date_level=False)
|
|
||||||
|
|
||||||
if not best_file:
|
|
||||||
add_error_to_db("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')}"
|
|
||||||
|
|
||||||
if is_tower_i:
|
|
||||||
# TowerI 模式:.bin 文件存入磁盘
|
|
||||||
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)
|
|
||||||
add_error_to_db("106网站", name, "运行正常 (Bin已存盘)", today_str)
|
|
||||||
else:
|
|
||||||
# Tower_ 模式: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:
|
|
||||||
clean_content = process_text_content(raw_content)
|
|
||||||
add_error_to_db("106网站", name, "运行正常", today_str, content=clean_content)
|
|
||||||
else:
|
|
||||||
add_error_to_db("106网站", name, "JSON内容为空", best_date[2])
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_error_to_db("106网站", name, f"站点处理崩溃: {str(e)}")
|
|
||||||
except Exception as e:
|
|
||||||
add_error_to_db("106网站", "全局逻辑", str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# --- 82 业务逻辑 ---
|
|
||||||
|
|
||||||
def run_82_logic():
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
data = r.json()
|
|
||||||
if not data:
|
|
||||||
add_error_to_db("82网站", sid, "返回 Null 数据")
|
|
||||||
continue
|
|
||||||
|
|
||||||
latest = str(data.get('date', ['N/A'])[-1])
|
|
||||||
status_msg = "当天已同步" if latest.startswith(today_fmt) else "数据滞后"
|
|
||||||
# 82网站数据通常直接存为文件备查,记录入库
|
|
||||||
add_error_to_db("82网站", sid, status_msg, latest)
|
|
||||||
|
|
||||||
# 可选:将82网站的完整JSON也存入content
|
|
||||||
# db.session.query(ErrorLog).filter_by(name=sid).update({"content": json.dumps(data, ensure_ascii=False)})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
add_error_to_db("82网站", sid, f"请求异常: {str(e)}")
|
|
||||||
except Exception as e:
|
|
||||||
add_error_to_db("82网站", "初始化模块", str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# --- 任务调度 ---
|
|
||||||
|
|
||||||
def background_worker():
|
|
||||||
global is_running
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
# 1. 覆盖逻辑:清空旧数据
|
|
||||||
ErrorLog.query.delete()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 2. 执行爬虫
|
|
||||||
run_106_logic()
|
|
||||||
run_82_logic()
|
|
||||||
|
|
||||||
# 3. 最终提交
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"后台任务出错: {e}")
|
|
||||||
finally:
|
|
||||||
is_running = False
|
|
||||||
|
|
||||||
|
|
||||||
# --- API 路由 ---
|
|
||||||
|
|
||||||
@app.route('/api/run', methods=['POST'])
|
|
||||||
def trigger_run():
|
|
||||||
global is_running
|
|
||||||
if is_running:
|
|
||||||
return jsonify({"message": "Task already running"}), 400
|
|
||||||
is_running = True
|
|
||||||
threading.Thread(target=background_worker).start()
|
|
||||||
return jsonify({"message": "Task started"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/status', methods=['GET'])
|
|
||||||
def get_status():
|
|
||||||
return jsonify({"is_running": is_running})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/logs', methods=['GET'])
|
|
||||||
def get_logs():
|
|
||||||
logs = ErrorLog.query.order_by(ErrorLog.source.desc()).all()
|
|
||||||
return jsonify([{
|
|
||||||
"source": l.source,
|
|
||||||
"name": l.name,
|
|
||||||
"reason": l.reason,
|
|
||||||
"offset": l.offset,
|
|
||||||
"latest_time": l.latest_time,
|
|
||||||
"check_time": l.check_time,
|
|
||||||
"content": l.content
|
|
||||||
} for l in logs])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 使用 5000 端口,请确保前端 Vite 配置了正确的 Proxy
|
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
|
||||||
@ -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任务全部完成。")
|
|
||||||
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'}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
2_3banben/extensions.py
Normal file
10
2_3banben/extensions.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_apscheduler import APScheduler
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
|
||||||
|
# 这里只创建对象,不绑定 app
|
||||||
|
db = SQLAlchemy()
|
||||||
|
cors = CORS()
|
||||||
|
scheduler = APScheduler()
|
||||||
|
jwt = JWTManager()
|
||||||
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.
126
2_3banben/models.py
Normal file
126
2_3banben/models.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
# 引入 UserMixin 是 Flask 标准做法
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# 数据库 1: 业务数据 (默认数据库 / devices.db)
|
||||||
|
# =======================
|
||||||
|
|
||||||
|
class Device(db.Model):
|
||||||
|
__tablename__ = 'devices'
|
||||||
|
# 默认数据库,无需 bind_key
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), unique=True, index=True)
|
||||||
|
source = db.Column(db.String(50))
|
||||||
|
|
||||||
|
# 快照字段(爬虫自动更新)
|
||||||
|
status = db.Column(db.String(50))
|
||||||
|
current_value = db.Column(db.String(200))
|
||||||
|
latest_time = db.Column(db.String(50))
|
||||||
|
json_data = db.Column(db.Text) # 存储完整原始JSON
|
||||||
|
check_time = db.Column(db.String(50))
|
||||||
|
reason = db.Column(db.String(255))
|
||||||
|
offset = db.Column(db.String(50)) # 时间偏移量说明
|
||||||
|
|
||||||
|
# 手动录入字段 (管理员/工程师可改)
|
||||||
|
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, # 给前端图标用的状态 (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,
|
||||||
|
# 🟢 [新增] 返回维修人给前端显示
|
||||||
|
'maintainer': self.maintainer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceHistory(db.Model):
|
||||||
|
__tablename__ = 'device_history'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
device_id = db.Column(db.Integer, db.ForeignKey('devices.id'))
|
||||||
|
|
||||||
|
data_time = db.Column(db.String(50))
|
||||||
|
status = db.Column(db.String(50))
|
||||||
|
result_data = db.Column(db.String(200), default="")
|
||||||
|
json_data = db.Column(db.Text)
|
||||||
|
file_path = db.Column(db.String(255))
|
||||||
|
recorded_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceLog(db.Model):
|
||||||
|
__tablename__ = 'maintenance_logs'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
device_name = db.Column(db.String(100), nullable=False)
|
||||||
|
engineer = db.Column(db.String(50))
|
||||||
|
location = db.Column(db.String(100))
|
||||||
|
content = db.Column(db.Text)
|
||||||
|
timestamp = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'device_name': self.device_name,
|
||||||
|
'engineer': self.engineer or '',
|
||||||
|
'location': self.location or '',
|
||||||
|
'content': self.content,
|
||||||
|
'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# 数据库 2: 用户管理 (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)
|
||||||
2
2_3banben/routes/__init__.py
Normal file
2
2_3banben/routes/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# routes/__init__.py
|
||||||
|
# 这是一个空文件,用于将 routes 文件夹标识为 Python 包。
|
||||||
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})
|
||||||
27
2_3banben/routes/web.py
Normal file
27
2_3banben/routes/web.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
from flask import Blueprint, send_from_directory
|
||||||
|
# 👇 确保 config.py 在根目录,且能被引用
|
||||||
|
from config import get_static_path
|
||||||
|
|
||||||
|
web_bp = Blueprint('web', __name__)
|
||||||
|
|
||||||
|
@web_bp.route('/')
|
||||||
|
def index():
|
||||||
|
"""访问根路径时,返回 dist/index.html"""
|
||||||
|
try:
|
||||||
|
return send_from_directory(get_static_path(), 'index.html')
|
||||||
|
except Exception as e:
|
||||||
|
return f"前端资源未找到,请确认 dist 文件夹是否存在。错误信息: {e}", 404
|
||||||
|
|
||||||
|
@web_bp.route('/<path:path>')
|
||||||
|
def static_files(path):
|
||||||
|
"""访问 /css, /js 等静态资源"""
|
||||||
|
static_folder = get_static_path()
|
||||||
|
file_path = os.path.join(static_folder, path)
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
return send_from_directory(static_folder, path)
|
||||||
|
|
||||||
|
# 路由回退:解决 Vue History 模式刷新 404 问题
|
||||||
|
# 如果找不到文件,就返回 index.html,让 Vue 路由去处理
|
||||||
|
return send_from_directory(static_folder, 'index.html')
|
||||||
2
2_3banben/services/__init__.py
Normal file
2
2_3banben/services/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# services/__init__.py
|
||||||
|
# 这是一个空文件,用于将 services 文件夹标识为 Python 包。
|
||||||
37
2_3banben/services/core.py
Normal file
37
2_3banben/services/core.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# services/core.py
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from .crawler_106 import run_106_logic
|
||||||
|
from .crawler_82 import run_82_logic
|
||||||
|
|
||||||
|
task_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def execute_monitor_task():
|
||||||
|
"""
|
||||||
|
执行所有爬虫,返回一个大列表:
|
||||||
|
{'device_list': [item1, item2...], 'target_time': '...'}
|
||||||
|
"""
|
||||||
|
if task_lock.locked():
|
||||||
|
logging.warning(">>> 任务正在运行中,跳过")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with task_lock:
|
||||||
|
logging.info(">>> 开始执行监控任务...")
|
||||||
|
|
||||||
|
# 1. 获取 106 数据列表
|
||||||
|
list_106 = run_106_logic()
|
||||||
|
|
||||||
|
# 2. 获取 82 数据列表
|
||||||
|
list_82 = run_82_logic()
|
||||||
|
|
||||||
|
# 3. 合并
|
||||||
|
combined_list = list_106 + list_82
|
||||||
|
|
||||||
|
logging.info(f">>> 任务完成,共获取 {len(combined_list)} 条数据")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'device_list': combined_list,
|
||||||
|
'target_time': None, # 具体时间已在 item 里
|
||||||
|
'temp_file_path': None # 废弃旧逻辑,文件路径已在 item 里
|
||||||
|
}
|
||||||
159
2_3banben/services/crawler_106.py
Normal file
159
2_3banben/services/crawler_106.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# services/crawler_106.py
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
CONFIG = Config.CRAWLER_CONFIG["106"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_temp_dir():
|
||||||
|
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
temp_dir = os.path.join(base_dir, 'instance', 'temp')
|
||||||
|
if not os.path.exists(temp_dir):
|
||||||
|
os.makedirs(temp_dir)
|
||||||
|
return temp_dir
|
||||||
|
|
||||||
|
|
||||||
|
def get_106_dynamic_token(port):
|
||||||
|
try:
|
||||||
|
login_url = f"http://106.75.72.40:{port}/api/login"
|
||||||
|
resp = requests.post(login_url, json=CONFIG["login_payload"], timeout=10)
|
||||||
|
return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_closest_item(items, is_date_level=True):
|
||||||
|
if not items or not isinstance(items, list): return None
|
||||||
|
today = datetime.now()
|
||||||
|
scored_items = []
|
||||||
|
for item in items:
|
||||||
|
name_val = item.get('name', '')
|
||||||
|
path_val = item.get('path', '')
|
||||||
|
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():
|
||||||
|
"""返回 result_list, 每个元素是一个字典"""
|
||||||
|
results = []
|
||||||
|
print(">>> [106爬虫] 启动...")
|
||||||
|
today_str = datetime.now().strftime("%Y_%m_%d")
|
||||||
|
main_headers = {"Authorization": CONFIG["primary_auth"], "User-Agent": "Mozilla/5.0"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(CONFIG["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
|
||||||
|
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): continue
|
||||||
|
|
||||||
|
# 构建基础数据包
|
||||||
|
data_packet = {
|
||||||
|
'source': '106网站',
|
||||||
|
'name': name,
|
||||||
|
'status': '正常',
|
||||||
|
'value': '',
|
||||||
|
'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
'raw_json': {},
|
||||||
|
'temp_file': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if str(item.get('status')).lower() != 'online':
|
||||||
|
data_packet['status'] = '离线'
|
||||||
|
data_packet['value'] = f"状态: {item.get('status')}"
|
||||||
|
results.append(data_packet)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = item.get('conf', {}).get('remote_port')
|
||||||
|
token = get_106_dynamic_token(port)
|
||||||
|
if not token:
|
||||||
|
data_packet['status'] = '异常'
|
||||||
|
data_packet['value'] = "Token获取失败"
|
||||||
|
results.append(data_packet)
|
||||||
|
continue
|
||||||
|
|
||||||
|
headers = {"Authorization": CONFIG["primary_auth"], "x-auth": token}
|
||||||
|
api_root = "/api/resources/Data/" if is_tower_underscore else "/api/resources/data/"
|
||||||
|
|
||||||
|
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
|
||||||
|
best_date = find_closest_item(res1.json().get('items', []), True)
|
||||||
|
|
||||||
|
if not best_date or best_date[2] != today_str:
|
||||||
|
data_packet['value'] = "未找到今日文件夹"
|
||||||
|
data_packet['target_time'] = best_date[2] if best_date else "N/A"
|
||||||
|
results.append(data_packet)
|
||||||
|
continue
|
||||||
|
|
||||||
|
data_packet['target_time'] = best_date[2] # 实际数据时间
|
||||||
|
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:
|
||||||
|
data_packet['value'] = "今日文件夹为空"
|
||||||
|
results.append(data_packet)
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_item = best_file[1]
|
||||||
|
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
|
||||||
|
|
||||||
|
# 核心逻辑:获取内容
|
||||||
|
if is_tower_i:
|
||||||
|
# 下载二进制文件
|
||||||
|
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
|
||||||
|
res3 = requests.get(download_url, headers=headers, timeout=20, stream=True)
|
||||||
|
if res3.status_code == 200:
|
||||||
|
safe_name = f"{name}_{datetime.now().strftime('%H%M%S')}.db"
|
||||||
|
temp_path = os.path.join(get_temp_dir(), safe_name)
|
||||||
|
with open(temp_path, 'wb') as f:
|
||||||
|
f.write(res3.content)
|
||||||
|
|
||||||
|
data_packet['temp_file'] = temp_path # 🔥 传递给API
|
||||||
|
data_packet['value'] = f"Binary Downloaded: {len(res3.content)} bytes"
|
||||||
|
data_packet['raw_json'] = file_item # 用文件属性充当RawData
|
||||||
|
else:
|
||||||
|
data_packet['status'] = '异常'
|
||||||
|
data_packet['value'] = f"下载失败: {res3.status_code}"
|
||||||
|
else:
|
||||||
|
# JSON 内容
|
||||||
|
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
|
||||||
|
res3 = requests.get(file_api_url, headers=headers, timeout=20)
|
||||||
|
try:
|
||||||
|
json_content = res3.json()
|
||||||
|
data_packet['raw_json'] = json_content # 🔥 完整保存
|
||||||
|
data_packet['value'] = json_content.get('content', '')
|
||||||
|
except:
|
||||||
|
data_packet['value'] = "JSON解析失败"
|
||||||
|
|
||||||
|
results.append(data_packet)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
data_packet['status'] = '异常'
|
||||||
|
data_packet['value'] = str(e)[:50]
|
||||||
|
results.append(data_packet)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"106 Crawler Error: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
62
2_3banben/services/crawler_82.py
Normal file
62
2_3banben/services/crawler_82.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# services/crawler_82.py
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from lxml import etree
|
||||||
|
from config import Config
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
CONFIG = Config.CRAWLER_CONFIG["82"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_82_logic():
|
||||||
|
"""返回 result_list"""
|
||||||
|
results = []
|
||||||
|
print(">>> [82爬虫] 启动...")
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.post(f"{CONFIG['base_url']}/login.php", data=CONFIG["login"], timeout=10)
|
||||||
|
resp = session.post(f"{CONFIG['base_url']}/GetStationList.php", timeout=10)
|
||||||
|
html = etree.HTML(resp.content)
|
||||||
|
if html is None: return []
|
||||||
|
|
||||||
|
stations = html.xpath('//option/@value')
|
||||||
|
for sid in [s for s in stations if s]:
|
||||||
|
data_packet = {
|
||||||
|
'source': '82网站',
|
||||||
|
'name': str(sid),
|
||||||
|
'status': '正常',
|
||||||
|
'value': '',
|
||||||
|
'target_time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
'raw_json': {},
|
||||||
|
'temp_file': None
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = session.post(f"{CONFIG['base_url']}/getLastWeatherData.php", data=str(sid),
|
||||||
|
headers={'Content-Type': 'text/plain'}, timeout=10)
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except:
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if data:
|
||||||
|
d_list = data.get('date', [])
|
||||||
|
latest = str(d_list[-1]) if d_list else "N/A"
|
||||||
|
data_packet['target_time'] = latest
|
||||||
|
data_packet['value'] = f"Data Points: {len(d_list)}"
|
||||||
|
data_packet['raw_json'] = data # 🔥 存完整JSON
|
||||||
|
else:
|
||||||
|
data_packet['status'] = '异常'
|
||||||
|
data_packet['value'] = "返回空数据"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
data_packet['status'] = '异常'
|
||||||
|
data_packet['value'] = "单个采集失败"
|
||||||
|
|
||||||
|
results.append(data_packet)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"82 Crawler Error: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
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
|
|
||||||
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,289 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<el-card shadow="never" class="main-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="header-row">
|
|
||||||
<div class="left-panel">
|
|
||||||
<h2 class="sys-title">📡 终端数据监控大屏</h2>
|
|
||||||
<div class="sys-status">
|
|
||||||
<span v-if="isRunning" class="status-running">
|
|
||||||
<el-icon class="is-loading"><Loading /></el-icon> 正在与远程服务器同步数据...
|
|
||||||
</span>
|
|
||||||
<span v-else class="status-idle">
|
|
||||||
<el-icon><CircleCheck /></el-icon> 数据已更新 ({{ lastUpdateTime }})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh" round icon="Refresh">
|
|
||||||
立即刷新同步
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<div class="filter-section">
|
|
||||||
<el-radio-group v-model="filters.site" size="default">
|
|
||||||
<el-radio-button label="all">全部来源</el-radio-button>
|
|
||||||
<el-radio-button label="106">106 代理</el-radio-button>
|
|
||||||
<el-radio-button label="82">82 气象站</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
|
|
||||||
<el-input
|
|
||||||
v-model="filters.keyword"
|
|
||||||
placeholder="搜索设备名称..."
|
|
||||||
prefix-icon="Search"
|
|
||||||
style="width: 220px; margin-left: 20px;"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="action-section">
|
|
||||||
<el-checkbox v-model="showHidden" label="显示已屏蔽" border style="margin-right: 15px"/>
|
|
||||||
<el-button
|
|
||||||
type="warning"
|
|
||||||
plain
|
|
||||||
icon="Hide"
|
|
||||||
:disabled="selectedRows.length === 0"
|
|
||||||
@click="hideSelectedDevices"
|
|
||||||
>
|
|
||||||
屏蔽选中 ({{ selectedRows.length }})
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table
|
|
||||||
:data="sortedData"
|
|
||||||
style="width: 100%; margin-top: 20px;"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
height="650"
|
|
||||||
v-loading="isRunning"
|
|
||||||
@selection-change="val => selectedRows = val"
|
|
||||||
>
|
|
||||||
<el-table-column type="selection" width="50" align="center" />
|
|
||||||
|
|
||||||
<el-table-column prop="source" label="来源" width="100" align="center">
|
|
||||||
<template #default="scope">
|
|
||||||
<el-tag size="small" effect="plain">{{ scope.row.source }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
|
|
||||||
<el-table-column label="设备名称" min-width="220">
|
|
||||||
<template #default="scope">
|
|
||||||
<el-link type="primary" :underline="false" @click="showDetails(scope.row)" class="device-link">
|
|
||||||
{{ scope.row.name }}
|
|
||||||
</el-link>
|
|
||||||
<el-tag v-if="isHidden(scope.row.name)" type="info" size="small" style="margin-left:8px">已屏蔽</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
|
|
||||||
<el-table-column label="运行状态" width="140" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="getStatusType(row)" effect="dark" round>
|
|
||||||
{{ getStatusLabel(row) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
|
|
||||||
<el-table-column prop="reason" label="状态详情" min-width="200">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span :style="{ color: getStatusColor(row) }">
|
|
||||||
{{ row.reason }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
|
|
||||||
<el-table-column prop="offset" label="数据时效" width="120" align="center" />
|
|
||||||
|
|
||||||
<el-table-column prop="latest_time" label="最新日期" width="160" align="center" />
|
|
||||||
|
|
||||||
<el-table-column label="管理" width="100" align="center" v-if="showHidden">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button v-if="isHidden(row.name)" type="info" link @click="restoreDevice(row.name)">恢复</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="footer-stats">
|
|
||||||
<span>总监控: {{ rawData.length }}</span> |
|
|
||||||
<span style="color: #F56C6C">严重问题: {{ stats.critical }}</span> |
|
|
||||||
<span style="color: #E6A23C">滞后警告: {{ stats.warning }}</span>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-drawer
|
|
||||||
v-model="drawerVisible"
|
|
||||||
title="设备数据详情"
|
|
||||||
size="45%"
|
|
||||||
destroy-on-close
|
|
||||||
>
|
|
||||||
<div v-if="activeDevice" class="drawer-content">
|
|
||||||
<el-descriptions :title="`站点名称:${activeDevice.name}`" :column="1" border>
|
|
||||||
<el-descriptions-item label="所属来源">{{ activeDevice.source }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="当前状态">
|
|
||||||
<el-tag :type="getStatusType(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="检测到最新时间">{{ activeDevice.latest_time }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="异常原因">{{ activeDevice.reason }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
|
|
||||||
<h3 style="margin: 25px 0 10px 0;">📦 原始 JSON 数据报文</h3>
|
|
||||||
<div class="json-container">
|
|
||||||
<json-viewer
|
|
||||||
v-if="parsedJson"
|
|
||||||
:value="parsedJson"
|
|
||||||
:expand-depth="5"
|
|
||||||
copyable
|
|
||||||
boxed
|
|
||||||
sort
|
|
||||||
/>
|
|
||||||
<el-empty v-else description="该站点暂无详细 JSON 数据内容" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-drawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
|
||||||
import axios from 'axios'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
// --- 数据响应式变量 ---
|
|
||||||
const rawData = ref([])
|
|
||||||
const isRunning = ref(false)
|
|
||||||
const lastUpdateTime = ref('-')
|
|
||||||
const selectedRows = ref([])
|
|
||||||
const showHidden = ref(false)
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
|
|
||||||
// 初始值设为 null,模板中通过 v-if 保护
|
|
||||||
const activeDevice = ref(null)
|
|
||||||
|
|
||||||
const filters = reactive({ site: 'all', keyword: '' })
|
|
||||||
const ignoredDevices = ref(JSON.parse(localStorage.getItem('ignored_list') || '[]'))
|
|
||||||
|
|
||||||
// --- 逻辑处理 ---
|
|
||||||
|
|
||||||
const getStatusLevel = (row) => {
|
|
||||||
if (!row || !row.reason) return 'success'
|
|
||||||
if (row.reason.includes('离线') || row.reason.includes('失败')) return 'critical'
|
|
||||||
if (row.offset && row.offset.includes('滞后')) return 'warning'
|
|
||||||
return 'success'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusType = (row) => {
|
|
||||||
const level = getStatusLevel(row)
|
|
||||||
return level === 'critical' ? 'danger' : (level === 'warning' ? 'warning' : 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusLabel = (row) => {
|
|
||||||
const level = getStatusLevel(row)
|
|
||||||
return level === 'critical' ? '连接异常' : (level === 'warning' ? '同步滞后' : '运行正常')
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (row) => {
|
|
||||||
const level = getStatusLevel(row)
|
|
||||||
return level === 'critical' ? '#F56C6C' : (level === 'warning' ? '#E6A23C' : '#606266')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHidden = (name) => ignoredDevices.value.includes(name)
|
|
||||||
|
|
||||||
const sortedData = computed(() => {
|
|
||||||
let list = rawData.value.filter(item => {
|
|
||||||
const matchSite = filters.site === 'all' || item.source.includes(filters.site)
|
|
||||||
const matchKey = !filters.keyword || item.name.toLowerCase().includes(filters.keyword.toLowerCase())
|
|
||||||
const hideLogic = showHidden.value ? true : !isHidden(item.name)
|
|
||||||
return matchSite && matchKey && hideLogic
|
|
||||||
})
|
|
||||||
return list.sort((a, b) => {
|
|
||||||
const weight = { 'critical': 3, 'warning': 2, 'success': 1 }
|
|
||||||
return weight[getStatusLevel(b)] - weight[getStatusLevel(a)]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const stats = computed(() => ({
|
|
||||||
critical: rawData.value.filter(r => getStatusLevel(r) === 'critical').length,
|
|
||||||
warning: rawData.value.filter(r => getStatusLevel(r) === 'warning').length
|
|
||||||
}))
|
|
||||||
|
|
||||||
const parsedJson = computed(() => {
|
|
||||||
if (!activeDevice.value || !activeDevice.value.content) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(activeDevice.value.content)
|
|
||||||
} catch (e) {
|
|
||||||
return activeDevice.value.content
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- 交互方法 ---
|
|
||||||
|
|
||||||
const showDetails = (row) => {
|
|
||||||
activeDevice.value = row
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideSelectedDevices = () => {
|
|
||||||
const names = selectedRows.value.map(r => r.name)
|
|
||||||
ignoredDevices.value = [...new Set([...ignoredDevices.value, ...names])]
|
|
||||||
localStorage.setItem('ignored_list', JSON.stringify(ignoredDevices.value))
|
|
||||||
ElMessage.success('已屏蔽选中设备')
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreDevice = (name) => {
|
|
||||||
ignoredDevices.value = ignoredDevices.value.filter(n => n !== name)
|
|
||||||
localStorage.setItem('ignored_list', JSON.stringify(ignoredDevices.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
|
||||||
try {
|
|
||||||
const res = await axios.get('/api/logs')
|
|
||||||
rawData.value = res.data
|
|
||||||
if (res.data.length > 0) lastUpdateTime.value = res.data[0].check_time
|
|
||||||
} catch (e) {
|
|
||||||
console.error("无法获取日志数据")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkStatus = async () => {
|
|
||||||
try {
|
|
||||||
const res = await axios.get('/api/status')
|
|
||||||
isRunning.value = res.data.is_running
|
|
||||||
if (isRunning.value) {
|
|
||||||
setTimeout(checkStatus, 3000)
|
|
||||||
} else {
|
|
||||||
fetchLogs()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
isRunning.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleManualRefresh = async () => {
|
|
||||||
if (isRunning.value) return
|
|
||||||
try {
|
|
||||||
await axios.post('/api/run')
|
|
||||||
isRunning.value = true
|
|
||||||
checkStatus()
|
|
||||||
} catch (e) {
|
|
||||||
ElMessage.error('启动同步失败,请检查后端连接')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
checkStatus()
|
|
||||||
fetchLogs()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.container { padding: 20px; max-width: 1400px; margin: 0 auto; font-family: sans-serif; }
|
|
||||||
.header-row { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.sys-title { margin: 0; color: #303133; }
|
|
||||||
.sys-status { font-size: 13px; color: #909399; margin-top: 5px; }
|
|
||||||
.toolbar { background: #f8f9fa; padding: 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; margin-top: 20px; border: 1px solid #ebeef5; }
|
|
||||||
.device-link { font-weight: bold; }
|
|
||||||
.footer-stats { margin-top: 20px; text-align: right; color: #606266; font-size: 14px; }
|
|
||||||
.json-container { border: 1px solid #eee; border-radius: 4px; overflow: hidden; }
|
|
||||||
.drawer-content { padding: 0 5px; }
|
|
||||||
</style>
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
// vite.config.js
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://127.0.0.1:5000', // 必须指向你的 Flask 地址
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path // 保持路径 /api 不变
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -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>
|
|
||||||
@ -8,7 +8,7 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
web_dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
@ -10,9 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.1.0",
|
"@element-plus/icons-vue": "^2.1.0",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.3.14",
|
"element-plus": "^2.3.14",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-json-viewer": "^3.0.4"
|
"vue-json-viewer": "^3.0.4",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "4.5.0",
|
"@vitejs/plugin-vue": "4.5.0",
|
||||||
@ -543,6 +545,11 @@
|
|||||||
"@vue/shared": "3.5.26"
|
"@vue/shared": "3.5.26"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||||
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.26",
|
"version": "3.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
||||||
@ -760,6 +767,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/element-plus": {
|
"node_modules/element-plus": {
|
||||||
"version": "2.13.0",
|
"version": "2.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
|
||||||
@ -1177,6 +1193,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
|
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
|
||||||
@ -1262,6 +1283,28 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.2.2"
|
"vue": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-router": {
|
||||||
|
"version": "4.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^6.6.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1560,6 +1603,11 @@
|
|||||||
"@vue/shared": "3.5.26"
|
"@vue/shared": "3.5.26"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@vue/devtools-api": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
|
||||||
|
},
|
||||||
"@vue/reactivity": {
|
"@vue/reactivity": {
|
||||||
"version": "3.5.26",
|
"version": "3.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
||||||
@ -1719,6 +1767,15 @@
|
|||||||
"gopd": "^1.2.0"
|
"gopd": "^1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"echarts": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"element-plus": {
|
"element-plus": {
|
||||||
"version": "2.13.0",
|
"version": "2.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.0.tgz",
|
||||||
@ -1999,6 +2056,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
|
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
|
||||||
},
|
},
|
||||||
|
"tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
||||||
|
},
|
||||||
"vite": {
|
"vite": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
|
||||||
@ -2030,6 +2092,22 @@
|
|||||||
"requires": {
|
"requires": {
|
||||||
"clipboard": "^2.0.4"
|
"clipboard": "^2.0.4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"vue-router": {
|
||||||
|
"version": "4.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
|
"requires": {
|
||||||
|
"@vue/devtools-api": "^6.6.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zrender": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -8,14 +8,16 @@
|
|||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.3.4",
|
|
||||||
"element-plus": "^2.3.14",
|
|
||||||
"axios": "^1.5.1",
|
|
||||||
"@element-plus/icons-vue": "^2.1.0",
|
"@element-plus/icons-vue": "^2.1.0",
|
||||||
"vue-json-viewer": "^3.0.4"
|
"axios": "^1.5.1",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"element-plus": "^2.3.14",
|
||||||
|
"vue": "^3.3.4",
|
||||||
|
"vue-json-viewer": "^3.0.4",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "4.5.0",
|
"@vitejs/plugin-vue": "4.5.0",
|
||||||
"@vitejs/plugin-vue": "4.5.0"
|
"vite": "4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
59
zhandianxinxi/光谱数据监控/src/App.vue
Normal file
59
zhandianxinxi/光谱数据监控/src/App.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="version-footer">
|
||||||
|
2.2版本(权限管理版) © 2026 Device Monitor
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// App.vue 保持简洁
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* --- 全局样式 --- */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
overflow-x: hidden; /* 防止 iOS 橡皮筋效果 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw; /* 强制不超过屏幕宽 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ 关键:内容区滚动控制 */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: auto; /* 允许横向滚动 */
|
||||||
|
-webkit-overflow-scrolling: touch; /* 移动端顺滑滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px 0;
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
flex-shrink: 0; /* 防止被压缩 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
@ -1,5 +1,7 @@
|
|||||||
|
// src/main.js
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
@ -7,11 +9,10 @@ import JsonViewer from 'vue-json-viewer'
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
// 注册所有图标
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
app.component(key, component)
|
app.component(key, component)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(ElementPlus)
|
|
||||||
app.use(JsonViewer)
|
app.use(JsonViewer)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
81
zhandianxinxi/光谱数据监控/src/router/index.js
Normal file
81
zhandianxinxi/光谱数据监控/src/router/index.js
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// 1. 引入页面组件
|
||||||
|
import Login from '../views/Login.vue'
|
||||||
|
import Dashboard from '../views/Dashboard.vue'
|
||||||
|
// 新增:引入用户管理页面 (确保你在 views 目录下创建了 UserManagement.vue)
|
||||||
|
import UserManagement from '../views/UserManagement.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
meta: { title: '系统登录' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: Dashboard,
|
||||||
|
meta: { title: '设备监控总览', requiresAuth: true }
|
||||||
|
},
|
||||||
|
// 新增:用户管理路由
|
||||||
|
{
|
||||||
|
path: '/user-management',
|
||||||
|
name: 'UserManagement',
|
||||||
|
component: UserManagement,
|
||||||
|
meta: { title: '客户权限管理', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/data-monitor',
|
||||||
|
name: 'CrawledData',
|
||||||
|
// 路由懒加载
|
||||||
|
component: () => import('../views/DataMonitor.vue'),
|
||||||
|
meta: { title: '数据爬取监控', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/logs',
|
||||||
|
name: 'MaintenanceLogs',
|
||||||
|
component: () => import('../views/MaintenanceLogs.vue'),
|
||||||
|
meta: { title: '维修日志中心', requiresAuth: true }
|
||||||
|
},
|
||||||
|
// 捕获所有未定义的路径,跳转回登录页
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// 核心:全局路由守卫
|
||||||
|
// =======================
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
// 1. 设置页面标题
|
||||||
|
if (to.meta.title) {
|
||||||
|
document.title = to.meta.title
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查登录状态 (从本地存储获取)
|
||||||
|
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true'
|
||||||
|
|
||||||
|
// 3. 鉴权逻辑
|
||||||
|
if (to.meta.requiresAuth && !isLoggedIn) {
|
||||||
|
// 如果页面需要登录但用户未登录,强行拦截并跳转到登录页
|
||||||
|
ElMessage.error('请先登录系统')
|
||||||
|
next({ name: 'Login' })
|
||||||
|
} else if (to.name === 'Login' && isLoggedIn) {
|
||||||
|
// 如果用户已登录却尝试访问登录页,直接送他去首页
|
||||||
|
next({ name: 'Dashboard' })
|
||||||
|
} else {
|
||||||
|
// 否则正常放行
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
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
|
||||||
545
zhandianxinxi/光谱数据监控/src/views/Dashboard.vue
Normal file
545
zhandianxinxi/光谱数据监控/src/views/Dashboard.vue
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<el-card shadow="never" class="main-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="header-row">
|
||||||
|
<div class="left-panel">
|
||||||
|
<h2 class="sys-title">📡 光谱设备监控</h2>
|
||||||
|
<div class="sys-status">
|
||||||
|
<el-tag type="info" effect="plain" round size="small">
|
||||||
|
<el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag :type="roleTagType" effect="dark" round size="small" style="margin-left: 10px;">
|
||||||
|
{{ roleDisplayName }}
|
||||||
|
<span v-if="currentUsername">({{ currentUsername }})</span>
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="isAdmin"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Avatar"
|
||||||
|
@click="goToUserManagement"
|
||||||
|
>
|
||||||
|
用户管理
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button type="info" plain icon="Document" @click="openLogCenter(null)">
|
||||||
|
日志中心
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="isAdmin"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
icon="RefreshRight"
|
||||||
|
:loading="runningTask"
|
||||||
|
@click="runManualMonitor"
|
||||||
|
>
|
||||||
|
系统检测
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
|
||||||
|
|
||||||
|
<div class="divider-mobile"></div>
|
||||||
|
|
||||||
|
<el-button type="danger" plain icon="SwitchButton" @click="handleLogout">
|
||||||
|
退出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="status-summary">
|
||||||
|
<el-tag color="#409EFF" effect="dark" class="legend-tag">修 (维修中)</el-tag>
|
||||||
|
<el-tag color="#F56C6C" effect="dark" class="legend-tag">离线/>7天</el-tag>
|
||||||
|
<el-tag color="#E6A23C" effect="dark" class="legend-tag">滞后1-7天</el-tag>
|
||||||
|
<el-tag color="#FAC858" effect="dark" class="legend-tag" style="color: #333">滞后24h</el-tag>
|
||||||
|
<el-tag color="#67C23A" effect="dark" class="legend-tag">正常</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-radio-group v-model="filters.status" @change="fetchData" size="default">
|
||||||
|
|
||||||
|
<el-radio-button label="all">全部</el-radio-button>
|
||||||
|
|
||||||
|
<el-radio-button label="abnormal" class="red-radio">
|
||||||
|
异常({{ summary.errorCount + summary.warningCount }})
|
||||||
|
</el-radio-button>
|
||||||
|
|
||||||
|
<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
|
||||||
|
v-model="filters.keyword"
|
||||||
|
placeholder="搜索设备名称..."
|
||||||
|
class="search-input"
|
||||||
|
prefix-icon="Search"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="filteredData"
|
||||||
|
border
|
||||||
|
v-loading="loading"
|
||||||
|
style="width: 100%; min-width: 950px;"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
:height="tableHeight"
|
||||||
|
:default-sort="{ prop: 'sortHours', order: 'descending' }"
|
||||||
|
>
|
||||||
|
<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; min-width: 60px;"
|
||||||
|
:style="{ color: row.statusLabelColor || '#fff' }"
|
||||||
|
>
|
||||||
|
{{ row.statusLabel }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="设备名称 (点击看图)" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div
|
||||||
|
class="device-name-wrapper"
|
||||||
|
:class="{ 'clickable-row': !row.is_hidden }"
|
||||||
|
@click="handleDeviceClick(row)"
|
||||||
|
>
|
||||||
|
<span class="device-name" :class="{ 'text-deleted': row.is_hidden }">
|
||||||
|
{{ formatDisplayName(row.name) }}
|
||||||
|
</span>
|
||||||
|
<el-icon v-if="!row.is_hidden" class="link-icon"><DataLine /></el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="安装地点" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.isEditingSite && canManageDevice" class="editing-cell">
|
||||||
|
<el-input
|
||||||
|
v-model="row.tempSite"
|
||||||
|
size="small"
|
||||||
|
@blur="saveSite(row)"
|
||||||
|
@keyup.enter="saveSite(row)"
|
||||||
|
class="site-input-inner"
|
||||||
|
placeholder="输入后回车"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="display-cell" @click="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>
|
||||||
|
|
||||||
|
<el-table-column label="数据时效" width="220" prop="sortHours" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div style="font-size: 13px;"><el-icon><Clock /></el-icon> {{ row.latest_time || '--' }}</div>
|
||||||
|
<div v-if="!row.is_maintaining && !row.is_hidden">
|
||||||
|
<div v-if="row.status === 'offline' || row.status === '已离线'" class="status-text error-text">⚠️ 设备已离线</div>
|
||||||
|
<div v-else-if="row.diffDays > 7" class="status-text error-text">⚠️ 严重滞后 {{ Math.floor(row.diffDays) }} 天</div>
|
||||||
|
<div v-else-if="row.diffHours > 24" class="status-text warning-text">⚠️ 滞后 {{ Math.floor(row.diffDays) }} 天</div>
|
||||||
|
<div v-else-if="!row.isToday" class="status-text slight-warning-text">⚠️ 昨日数据</div>
|
||||||
|
<div v-else class="status-text success-text">✅ 数据最新</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="row.is_maintaining" class="status-text maintenance-text">
|
||||||
|
🛠️ 工程师介入中
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-group">
|
||||||
|
<template v-if="row.is_hidden">
|
||||||
|
<el-button 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; margin-right: 8px;"
|
||||||
|
:before-change="() => handleMaintenanceBeforeChange(row)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
|
||||||
|
|
||||||
|
<el-popconfirm v-if="isAdmin" title="确定隐藏?" @confirm="toggleHidden(row, true)">
|
||||||
|
<template #reference>
|
||||||
|
<el-button type="danger" link icon="Delete">隐藏</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<DataMonitor ref="dataMonitorRef" />
|
||||||
|
<MaintenanceLogs ref="maintenanceLogsRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import request from '../utils/request'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Avatar } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// 引入子组件
|
||||||
|
import DataMonitor from './DataMonitor.vue'
|
||||||
|
import MaintenanceLogs from './MaintenanceLogs.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const runningTask = ref(false)
|
||||||
|
const rawData = ref([])
|
||||||
|
const lastCheckTime = ref('')
|
||||||
|
const windowHeight = ref(window.innerHeight)
|
||||||
|
const windowWidth = ref(window.innerWidth)
|
||||||
|
|
||||||
|
// --- 🔐 权限状态管理 ---
|
||||||
|
const 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 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, maintenanceCount: maintenance }
|
||||||
|
})
|
||||||
|
|
||||||
|
const goToUserManagement = () => { router.push('/user-management') }
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
router.push('/')
|
||||||
|
ElMessage.success('已安全退出')
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------
|
||||||
|
// 🟢 数据获取
|
||||||
|
// -----------------------------------------------------
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await request.get('/api/devices_overview')
|
||||||
|
|
||||||
|
const backendList = res.data.data || res.data
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
let processedData = backendList.map(item => {
|
||||||
|
const isHidden = item.is_hidden === true || item.is_hidden === 1
|
||||||
|
let diffDays = 0, diffHours = 0, isToday = false, validTime = false
|
||||||
|
|
||||||
|
if (item.latest_time && item.latest_time !== 'N/A') {
|
||||||
|
const cleanDateStr = item.latest_time.toString().replace(/_/g, '-')
|
||||||
|
const d = new Date(cleanDateStr)
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
validTime = true
|
||||||
|
const diffTime = now - d
|
||||||
|
const safeDiff = diffTime > 0 ? diffTime : 0
|
||||||
|
diffHours = safeDiff / (1000 * 60 * 60)
|
||||||
|
diffDays = safeDiff / (1000 * 60 * 60 * 24)
|
||||||
|
isToday = d.toDateString() === now.toDateString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序优先级
|
||||||
|
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';
|
||||||
|
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) {
|
||||||
|
statusColor = '#E6A23C'; statusLabel = '数据滞后'; statusType = 'warning';
|
||||||
|
} else if (!isToday) {
|
||||||
|
statusColor = '#FAC858'; statusLabel = '昨日数据'; statusType = 'slight-warning'; statusLabelColor = '#333';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item, is_hidden: isHidden, diffDays, diffHours, sortHours, isToday,
|
||||||
|
statusColor, statusLabel, statusType, statusLabelColor, isEditingSite: false, tempSite: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
processedData.sort((a, b) => b.sortHours - a.sortHours)
|
||||||
|
rawData.value = processedData
|
||||||
|
lastCheckTime.value = new Date().toLocaleString()
|
||||||
|
} catch (e) {
|
||||||
|
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 () => {
|
||||||
|
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(() => {
|
||||||
|
const inputs = document.querySelectorAll('.site-input-inner input')
|
||||||
|
if (inputs.length > 0) inputs[inputs.length - 1].focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSite = async (row) => {
|
||||||
|
if (!row.isEditingSite) return
|
||||||
|
const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false
|
||||||
|
if (oldVal === row.tempSite) return
|
||||||
|
try {
|
||||||
|
await request.post('/api/update_site', { name: row.name, site: row.tempSite })
|
||||||
|
ElMessage.success('已更新')
|
||||||
|
} catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaintenanceBeforeChange = (row) => {
|
||||||
|
if (!canManageDevice.value) return Promise.reject()
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const newVal = !row.is_maintaining
|
||||||
|
const maintainerName = currentUsername.value || '工程师';
|
||||||
|
|
||||||
|
request.post('/api/toggle_maintenance', {
|
||||||
|
name: row.name,
|
||||||
|
is_maintaining: newVal,
|
||||||
|
maintainer: newVal ? maintainerName : null
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
row.is_maintaining = newVal;
|
||||||
|
row.maintainer = newVal ? maintainerName : null;
|
||||||
|
|
||||||
|
if (newVal) {
|
||||||
|
row.statusColor = '#409EFF';
|
||||||
|
row.statusLabel = `维修中 (${maintainerName})`;
|
||||||
|
row.statusType = 'maintenance';
|
||||||
|
} else {
|
||||||
|
row.statusLabel = '更新中...';
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success(newVal ? '已进入维修模式' : '已恢复');
|
||||||
|
fetchData();
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
ElMessage.error('操作失败');
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleHidden = async (row, targetState) => {
|
||||||
|
if (!isAdmin.value) return
|
||||||
|
try {
|
||||||
|
await request.post('/api/toggle_hidden', { name: row.name, is_hidden: targetState })
|
||||||
|
row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复')
|
||||||
|
} catch (e) { ElMessage.error('操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (row.is_hidden) return 'hidden-row'
|
||||||
|
if (row.statusType === 'maintenance') return 'maintenance-row'
|
||||||
|
if (row.statusType === 'error') return 'error-row'
|
||||||
|
if (row.statusType === 'warning') return 'warning-row'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
windowHeight.value = window.innerHeight
|
||||||
|
windowWidth.value = window.innerWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
userRole.value = localStorage.getItem('role') || 'client'
|
||||||
|
currentUsername.value = localStorage.getItem('username') || ''
|
||||||
|
fetchData()
|
||||||
|
window.addEventListener('resize', updateDimensions)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
|
||||||
|
.main-card { border-radius: 8px; overflow: visible; }
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sys-title { margin: 0; font-size: 20px; color: #303133; font-weight: 700; white-space: nowrap; }
|
||||||
|
.left-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
|
||||||
|
.legend-tag { font-weight: bold; border: none; font-size: 12px; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
.filter-section {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search-input { width: 220px; transition: width 0.3s; }
|
||||||
|
|
||||||
|
/* 🟢 自定义筛选按钮样式,增加辨识度 */
|
||||||
|
.red-radio :deep(.el-radio-button__inner) { color: #F56C6C; }
|
||||||
|
.red-radio.is-active :deep(.el-radio-button__inner) { background-color: #F56C6C; border-color: #F56C6C; color: #fff; box-shadow: -1px 0 0 0 #F56C6C; }
|
||||||
|
|
||||||
|
.blue-radio :deep(.el-radio-button__inner) { color: #409EFF; }
|
||||||
|
.blue-radio.is-active :deep(.el-radio-button__inner) { background-color: #409EFF; border-color: #409EFF; color: #fff; box-shadow: -1px 0 0 0 #409EFF; }
|
||||||
|
|
||||||
|
.gray-radio :deep(.el-radio-button__inner) { color: #909399; }
|
||||||
|
.gray-radio.is-active :deep(.el-radio-button__inner) { background-color: #909399; border-color: #909399; color: #fff; box-shadow: -1px 0 0 0 #909399; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.text-deleted { text-decoration: line-through; color: #999; }
|
||||||
|
.status-text { font-size: 12px; margin-top: 4px; font-weight: bold; }
|
||||||
|
.error-text { color: #F56C6C; }
|
||||||
|
.warning-text { color: #E6A23C; }
|
||||||
|
.success-text { color: #67C23A; }
|
||||||
|
.maintenance-text { color: #409EFF; font-weight: bold; }
|
||||||
|
|
||||||
|
.display-cell { padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.edit-icon { color: #409EFF; margin-left: 5px; }
|
||||||
|
|
||||||
|
: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; }
|
||||||
|
|
||||||
|
@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>
|
||||||
413
zhandianxinxi/光谱数据监控/src/views/DataMonitor.vue
Normal file
413
zhandianxinxi/光谱数据监控/src/views/DataMonitor.vue
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="📈 设备数据详情"
|
||||||
|
width="90%"
|
||||||
|
top="5vh"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="disposeCharts"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div class="dialog-header-bar">
|
||||||
|
<div class="device-info">
|
||||||
|
<span class="d-name">{{ formatDisplayName(deviceName) }}</span>
|
||||||
|
<el-tag size="small" type="info" v-if="currentSource">Source: {{ currentSource }}</el-tag>
|
||||||
|
<span class="latest-time-hint" v-if="dataTimestamp">
|
||||||
|
(数据时间: {{ dataTimestamp }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date-filter">
|
||||||
|
<span class="label">选择日期:</span>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="selectedDate"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
:disabled-date="disabledDate"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
@change="handleDateChange"
|
||||||
|
:clearable="false"
|
||||||
|
style="width: 150px;"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
icon="Refresh"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
@click="loadData"
|
||||||
|
style="margin-left: 10px"
|
||||||
|
title="刷新当前数据"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="monitor-dialog-content" v-loading="loading">
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && chartModules.length === 0"
|
||||||
|
:description="emptyText"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
v-for="(module, index) in chartModules"
|
||||||
|
:key="index"
|
||||||
|
class="chart-wrapper"
|
||||||
|
>
|
||||||
|
<div :ref="(el) => setChartRef(el, index)" class="echart-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick, onBeforeUnmount } from 'vue'
|
||||||
|
import request from '../utils/request' // 确保 request 工具路径正确
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
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('')
|
||||||
|
const selectedDate = ref('')
|
||||||
|
const dataTimestamp = ref('')
|
||||||
|
const chartModules = ref([])
|
||||||
|
const emptyText = ref('暂无数据')
|
||||||
|
|
||||||
|
// --- ECharts 实例管理 ---
|
||||||
|
let chartInstances = []
|
||||||
|
const chartRefs = ref([])
|
||||||
|
|
||||||
|
// 动态 Ref 设置函数
|
||||||
|
const setChartRef = (el, index) => {
|
||||||
|
if (el) chartRefs.value[index] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期禁用逻辑
|
||||||
|
const disabledDate = (time) => {
|
||||||
|
return time.getTime() > Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化设备名称
|
||||||
|
const formatDisplayName = (name) => (name ? name.toUpperCase().replace(/_/g, ' ') : '')
|
||||||
|
|
||||||
|
// 获取今天的日期字符串 YYYY-MM-DD
|
||||||
|
const getTodayString = () => {
|
||||||
|
const today = new Date()
|
||||||
|
const y = today.getFullYear()
|
||||||
|
const m = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(today.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 核心入口 ---
|
||||||
|
const open = (row) => {
|
||||||
|
visible.value = true
|
||||||
|
deviceName.value = row.name
|
||||||
|
currentSource.value = row.source
|
||||||
|
chartModules.value = []
|
||||||
|
|
||||||
|
// 处理初始日期
|
||||||
|
if (row.latest_time && row.latest_time !== 'N/A') {
|
||||||
|
dataTimestamp.value = row.latest_time
|
||||||
|
try {
|
||||||
|
const datePart = row.latest_time.split(' ')[0].split('T')[0]
|
||||||
|
selectedDate.value = datePart
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('时间格式解析异常,回退到今天', row.latest_time)
|
||||||
|
selectedDate.value = getTodayString()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedDate.value = getTodayString()
|
||||||
|
dataTimestamp.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期改变回调
|
||||||
|
const handleDateChange = () => {
|
||||||
|
dataTimestamp.value = ''
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 数据加载逻辑 (已修复崩溃问题) ---
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!deviceName.value || !selectedDate.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
chartModules.value = []
|
||||||
|
emptyText.value = '加载中...'
|
||||||
|
|
||||||
|
disposeCharts()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await request.get('/api/device_data_by_date', {
|
||||||
|
params: {
|
||||||
|
name: deviceName.value,
|
||||||
|
date: selectedDate.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. 获取原始数据
|
||||||
|
const rawContent = res.data.content
|
||||||
|
const source = res.data.source
|
||||||
|
|
||||||
|
// 2. 关键修复:安全转换。如果 content 是 null/undefined,转为空字符串;如果是对象,转为字符串。
|
||||||
|
let safeContent = ''
|
||||||
|
if (rawContent !== null && rawContent !== undefined) {
|
||||||
|
safeContent = typeof rawContent === 'object' ? JSON.stringify(rawContent) : String(rawContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveSource = source || currentSource.value
|
||||||
|
|
||||||
|
// 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: safeContent, // 传入处理后的安全字符串
|
||||||
|
source: effectiveSource
|
||||||
|
})
|
||||||
|
|
||||||
|
chartModules.value = modules
|
||||||
|
|
||||||
|
if (modules.length === 0) {
|
||||||
|
// 安全截取字符串,避免报错
|
||||||
|
console.warn('解析结果为空。原始内容片段:', safeContent.substring(0, 100))
|
||||||
|
emptyText.value = '数据解析失败 (格式不匹配)'
|
||||||
|
} else {
|
||||||
|
await nextTick()
|
||||||
|
initCharts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response && e.response.status === 404) {
|
||||||
|
emptyText.value = `${selectedDate.value} 无数据记录`
|
||||||
|
} else {
|
||||||
|
console.error('Data Load Error:', e)
|
||||||
|
emptyText.value = '数据加载异常'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 解析器:106 系列 ---
|
||||||
|
function parse106Data(content) {
|
||||||
|
if (typeof content !== 'string') return []
|
||||||
|
const modules = []
|
||||||
|
|
||||||
|
// 宽松正则:不强制开头,允许空格,允许跨行匹配
|
||||||
|
const blockRegex = /(?:FS\d_Info|Info)?[\s\S]*?Model\s*,\s*([^,\r\n]+)[\s\S]*?SN\s*,\s*([^,\r\n]+)[\s\S]*?Wavelength\s*,\s*([0-9\.,\s]+)/gi
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
for (let p = 1; p <= 4; p++) {
|
||||||
|
// 转义 Model 名称中的特殊字符
|
||||||
|
const escapedModel = modelRaw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
|
||||||
|
const pRegex = new RegExp(`${escapedModel}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
|
||||||
|
|
||||||
|
const dMatch = content.match(pRegex)
|
||||||
|
if (dMatch) {
|
||||||
|
const vals = dMatch[1].split(',').map((v) => parseFloat(v))
|
||||||
|
if (vals.some((v) => v !== null && !isNaN(v))) {
|
||||||
|
series.push({
|
||||||
|
name: `P${p}`,
|
||||||
|
data: vals,
|
||||||
|
color: ['#5470c6', '#91cc75', '#fac858', '#ee6666'][p - 1],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series.length) {
|
||||||
|
modules.push({ type: '106', model: modelRaw, sn: snRaw, xAxis: wavelengths, series })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 解析器:82 系列 ---
|
||||||
|
function parse82Data(content, deviceName) {
|
||||||
|
try {
|
||||||
|
const d = typeof content === 'string' ? JSON.parse(content) : content
|
||||||
|
|
||||||
|
if (d && (d.wavelenth || d.wavelength)) {
|
||||||
|
const xData = d.wavelenth || d.wavelength
|
||||||
|
return [{
|
||||||
|
type: '82',
|
||||||
|
title: deviceName,
|
||||||
|
xAxis: xData,
|
||||||
|
series: [
|
||||||
|
{ name: 'DownSpec', data: d.downspec, color: '#409EFF' },
|
||||||
|
{ name: 'UpSpec', data: d.upspec, color: '#67C23A' },
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('JSON Parse 82 Error', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 主解析入口 ---
|
||||||
|
function parseChartData(device) {
|
||||||
|
if (!device || !device.content) return []
|
||||||
|
|
||||||
|
// 这里的 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(contentStr, device.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ECharts 配置 ---
|
||||||
|
function getChartOption(moduleData, isMobile = false) {
|
||||||
|
const titleText = moduleData.type === '106'
|
||||||
|
? `Model: ${moduleData.model} (SN: ${moduleData.sn})`
|
||||||
|
: moduleData.title
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: titleText,
|
||||||
|
left: 'center',
|
||||||
|
top: 10,
|
||||||
|
textStyle: { fontSize: isMobile ? 14 : 16 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
confine: true,
|
||||||
|
axisPointer: { type: 'cross' }
|
||||||
|
},
|
||||||
|
legend: { top: 35, type: 'scroll' },
|
||||||
|
toolbox: {
|
||||||
|
feature: {
|
||||||
|
saveAsImage: { title: '保存' },
|
||||||
|
dataZoom: { title: { zoom: '缩放', back: '还原' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 80,
|
||||||
|
bottom: 30,
|
||||||
|
right: isMobile ? 10 : 40,
|
||||||
|
left: isMobile ? 40 : 50
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: moduleData.xAxis,
|
||||||
|
boundaryGap: false,
|
||||||
|
name: 'nm'
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 'dataMin',
|
||||||
|
max: 'dataMax',
|
||||||
|
scale: true
|
||||||
|
},
|
||||||
|
series: moduleData.series.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line',
|
||||||
|
data: s.data,
|
||||||
|
connectNulls: false,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: { width: 1.5, color: s.color },
|
||||||
|
areaStyle: { opacity: 0.1, color: s.color },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ECharts 初始化 ---
|
||||||
|
const initCharts = () => {
|
||||||
|
if (chartModules.value.length === 0) return
|
||||||
|
const isMobile = window.innerWidth < 768
|
||||||
|
|
||||||
|
chartModules.value.forEach((mod, index) => {
|
||||||
|
const el = chartRefs.value[index]
|
||||||
|
if (el) {
|
||||||
|
const oldInstance = echarts.getInstanceByDom(el)
|
||||||
|
if (oldInstance) oldInstance.dispose()
|
||||||
|
|
||||||
|
const chart = echarts.init(el)
|
||||||
|
chart.setOption(getChartOption(mod, isMobile))
|
||||||
|
chartInstances.push(chart)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ECharts 销毁 ---
|
||||||
|
const disposeCharts = () => {
|
||||||
|
chartInstances.forEach((chart) => chart && chart.dispose())
|
||||||
|
chartInstances = []
|
||||||
|
chartRefs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
onBeforeUnmount(() => disposeCharts())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-header-bar {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #EBEEF5;
|
||||||
|
}
|
||||||
|
.device-info { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.d-name { font-size: 18px; font-weight: bold; color: #303133; }
|
||||||
|
.latest-time-hint { font-size: 12px; color: #909399; margin-left: 5px; }
|
||||||
|
|
||||||
|
.date-filter { display: flex; align-items: center; }
|
||||||
|
.label { font-size: 14px; color: #606266; margin-right: 8px; }
|
||||||
|
|
||||||
|
.monitor-dialog-content { min-height: 400px; padding: 10px; }
|
||||||
|
.charts-scroll-container { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.chart-wrapper {
|
||||||
|
background: #fff; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 10px; border: 1px solid #EBEEF5;
|
||||||
|
}
|
||||||
|
.echart-container { width: 100%; height: 450px; }
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.dialog-header-bar { flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||||
|
.echart-container { height: 350px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
395
zhandianxinxi/光谱数据监控/src/views/MaintenanceLogs.vue
Normal file
395
zhandianxinxi/光谱数据监控/src/views/MaintenanceLogs.vue
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="🔧 维修与故障日志中心"
|
||||||
|
width="85%"
|
||||||
|
top="5vh"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div class="logs-container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
@change="fetchLogs"
|
||||||
|
style="width: 260px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索:设备名 / 工程师 / 内容"
|
||||||
|
style="width: 300px"
|
||||||
|
: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" :disabled="isSearchLocked">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group" v-if="userRole !== 'client'">
|
||||||
|
<el-button type="success" icon="Plus" @click="() => openAddDialog()">新增记录</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="logsList"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
v-loading="loading"
|
||||||
|
height="550"
|
||||||
|
style="width: 100%; margin-top: 15px"
|
||||||
|
>
|
||||||
|
<el-table-column prop="timestamp" label="记录时间" width="170" sortable />
|
||||||
|
|
||||||
|
<el-table-column prop="device_name" label="设备名称" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag effect="plain">{{ formatDisplayName(row.device_name) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="location" label="地点" width="150" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column prop="engineer" label="工程师" width="120">
|
||||||
|
<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" v-if="userRole !== 'client'">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
icon="Edit"
|
||||||
|
@click="openEditDialog(row)"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-popconfirm
|
||||||
|
v-if="userRole === 'admin'"
|
||||||
|
title="确定删除这条记录吗?"
|
||||||
|
@confirm="deleteLog(row.id)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button type="danger" link icon="Delete">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="logDialog.visible"
|
||||||
|
:title="logDialog.isEdit ? '✏️ 修改维修记录' : '📝 新增维修记录'"
|
||||||
|
width="500px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-form :model="logDialog.form" label-width="80px" label-position="top">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="设备名称" required>
|
||||||
|
<el-autocomplete
|
||||||
|
v-model="logDialog.form.device_name"
|
||||||
|
:fetch-suggestions="querySearchDevice"
|
||||||
|
placeholder="必须选择现有设备"
|
||||||
|
:disabled="logDialog.isDeviceLocked || logDialog.isEdit"
|
||||||
|
style="width: 100%"
|
||||||
|
clearable
|
||||||
|
highlight-first-item
|
||||||
|
trigger-on-focus
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<span class="name">{{ formatDisplayName(item.value) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-autocomplete>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="工程师" required>
|
||||||
|
<el-input
|
||||||
|
v-model="logDialog.form.engineer"
|
||||||
|
:placeholder="userRole === 'engineer' ? '' : '请输入工程师姓名'"
|
||||||
|
:disabled="userRole === 'engineer'"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="地点">
|
||||||
|
<el-input v-model="logDialog.form.location" placeholder="例: 3号楼顶层" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="事件内容" required>
|
||||||
|
<el-input
|
||||||
|
v-model="logDialog.form.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="描述故障原因及处理结果..."
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="logDialog.visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitLog" :loading="logDialog.submitting">
|
||||||
|
{{ logDialog.isEdit ? '保存修改' : '提交保存' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</el-dialog>
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import request from '../utils/request'
|
||||||
|
import { ElMessage, ElConfigProvider } from 'element-plus'
|
||||||
|
import { Search, Plus, Delete, Edit, UserFilled } from '@element-plus/icons-vue'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
|
// --- 状态定义 ---
|
||||||
|
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: '',
|
||||||
|
engineer: '',
|
||||||
|
location: '',
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统一获取认证信息
|
||||||
|
const refreshAuth = () => {
|
||||||
|
userRole.value = localStorage.getItem('role') || 'client'
|
||||||
|
currentUsername.value = localStorage.getItem('username') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🟢 获取设备库(核心)
|
||||||
|
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 {
|
||||||
|
const params = { keyword: keyword.value }
|
||||||
|
if (dateRange.value && dateRange.value.length === 2) {
|
||||||
|
params.start_date = dateRange.value[0]
|
||||||
|
params.end_date = dateRange.value[1]
|
||||||
|
}
|
||||||
|
const res = await request.get('/api/logs/list', { params })
|
||||||
|
logsList.value = res.data.data
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('获取日志失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 打开新增
|
||||||
|
const openAddDialog = () => {
|
||||||
|
refreshAuth()
|
||||||
|
logDialog.isEdit = false
|
||||||
|
|
||||||
|
let autoEngineer = ''
|
||||||
|
if (userRole.value === 'engineer') {
|
||||||
|
autoEngineer = currentUsername.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逻辑:如果搜索栏锁定了(从设备卡片进来的),直接锁定设备名
|
||||||
|
let prefillDevice = ''
|
||||||
|
let lockDevice = false
|
||||||
|
|
||||||
|
if (isSearchLocked.value && keyword.value) {
|
||||||
|
prefillDevice = keyword.value
|
||||||
|
lockDevice = true
|
||||||
|
}
|
||||||
|
|
||||||
|
logDialog.isDeviceLocked = lockDevice
|
||||||
|
|
||||||
|
logDialog.form = {
|
||||||
|
id: null,
|
||||||
|
device_name: prefillDevice,
|
||||||
|
engineer: autoEngineer,
|
||||||
|
location: '',
|
||||||
|
content: ''
|
||||||
|
}
|
||||||
|
logDialog.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 编辑
|
||||||
|
const openEditDialog = (row) => {
|
||||||
|
refreshAuth()
|
||||||
|
logDialog.isEdit = true
|
||||||
|
// 编辑模式禁止修改设备名(防止关联错误)
|
||||||
|
logDialog.isDeviceLocked = true
|
||||||
|
|
||||||
|
logDialog.form = {
|
||||||
|
id: row.id,
|
||||||
|
device_name: row.device_name,
|
||||||
|
engineer: userRole.value === 'engineer' ? currentUsername.value : row.engineer,
|
||||||
|
location: row.location,
|
||||||
|
content: row.content
|
||||||
|
}
|
||||||
|
logDialog.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 🟢 提交保存 (核心修改区)
|
||||||
|
const submitLog = async () => {
|
||||||
|
refreshAuth()
|
||||||
|
|
||||||
|
const inputDeviceName = logDialog.form.device_name;
|
||||||
|
|
||||||
|
// A. 基础非空校验
|
||||||
|
if (!inputDeviceName || !logDialog.form.content) {
|
||||||
|
return ElMessage.warning('请填写 设备名称 和 事件内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. 🔴 关键逻辑:校验设备名是否存在于设备库中
|
||||||
|
// 如果当前设备列表还没加载完(极少情况),尝试重新加载一次或者报错
|
||||||
|
if (allDevices.value.length === 0) {
|
||||||
|
await fetchAllDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查输入的设备名是否能在 allDevices 数组里找到 exact match
|
||||||
|
const isDeviceExist = allDevices.value.some(d => d.value === inputDeviceName);
|
||||||
|
|
||||||
|
// 如果设备名不在库中,且也不是空,直接拒绝
|
||||||
|
if (!isDeviceExist) {
|
||||||
|
return ElMessage.error(`设备 "${inputDeviceName}" 不存在!请从下拉列表中选择有效的设备。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C. 身份校验
|
||||||
|
if (userRole.value !== 'engineer' && !logDialog.form.engineer) {
|
||||||
|
return ElMessage.warning('请填写工程师姓名')
|
||||||
|
}
|
||||||
|
if (userRole.value === 'engineer') {
|
||||||
|
logDialog.form.engineer = currentUsername.value
|
||||||
|
}
|
||||||
|
|
||||||
|
logDialog.submitting = true
|
||||||
|
try {
|
||||||
|
const endpoint = logDialog.isEdit ? '/api/logs/update' : '/api/logs/add'
|
||||||
|
await request.post(endpoint, logDialog.form)
|
||||||
|
|
||||||
|
ElMessage.success(logDialog.isEdit ? '记录已更新' : '记录已添加')
|
||||||
|
logDialog.visible = false
|
||||||
|
fetchLogs()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.msg || '保存失败')
|
||||||
|
} finally {
|
||||||
|
logDialog.submitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 删除
|
||||||
|
const deleteLog = async (id) => {
|
||||||
|
try {
|
||||||
|
await request.post('/api/logs/delete', { id })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
fetchLogs()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('无权删除')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDisplayName = (n) => n ? n.toUpperCase().replace(/_/g, ' ') : ''
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logs-container { padding: 10px; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
|
||||||
|
.filter-group { display: flex; gap: 12px; }
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input.is-disabled .el-input__inner) {
|
||||||
|
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>
|
||||||
104
zhandianxinxi/光谱数据监控/src/views/login.vue
Normal file
104
zhandianxinxi/光谱数据监控/src/views/login.vue
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<el-card class="login-card">
|
||||||
|
<h2>🚀 设备监控系统登录</h2>
|
||||||
|
<el-form :model="loginForm" @keyup.enter="handleLogin">
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="用户名"
|
||||||
|
prefix-icon="User"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" style="width: 100%" @click="handleLogin">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { User, Lock } from '@element-plus/icons-vue' // 确保引入了图标
|
||||||
|
import request from '../utils/request'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const loginForm = ref({ username: '', password: '' })
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!loginForm.value.username || !loginForm.value.password) {
|
||||||
|
return ElMessage.warning('请输入用户名和密码')
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await 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', 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')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(data.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
// request.js 通常会处理网络错误,这里主要处理业务逻辑错误
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, #1d2b64 0%, #f8cdda 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 400px;
|
||||||
|
padding: 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>
|
||||||
30
zhandianxinxi/光谱数据监控/vite.config.js
Normal file
30
zhandianxinxi/光谱数据监控/vite.config.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// --- 强烈建议新增这一行 ---
|
||||||
|
// 这确保 index.html 引用 css/js 时使用相对路径,
|
||||||
|
// 避免 Flask 托管时出现找不到文件的 404 错误。
|
||||||
|
base: './',
|
||||||
|
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 关于这段 server 配置 ---
|
||||||
|
// 这里的配置仅在你自己电脑上写代码(npm run dev)时有效。
|
||||||
|
// 打包(npm run build)后,前端请求会直接发给同源的 Flask,
|
||||||
|
// 所以这里填什么 IP 对打包后的程序没有影响,不用改。
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:5000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user