27 Commits

Author SHA1 Message Date
a0080cecb3 权限系统完善,实现维修日志信息自动填写功能,同时优化设备分配页面设计 2026-01-12 10:03:48 +08:00
6735ad3a93 权限系统完善,实现维修日志信息自动填写功能,同时优化设备分配页面设计 2026-01-11 17:39:44 +08:00
94ff1ddf57 权限系统完善,实现维修日志信息自动填写功能,同时优化设备分配页面设计 2026-01-11 17:39:33 +08:00
ca03816668 2.3权限管理,基本盘完成,下一步修改设备管理弹窗设计,完善工程师日志写入设计 2026-01-09 17:22:12 +08:00
c416c8ad07 2.3权限管理,可添加运行 2026-01-09 15:18:24 +08:00
9c73e25937 2.3权限管理 2026-01-09 13:38:51 +08:00
43f049112f 适配手机端修改 2026-01-09 12:55:43 +08:00
ffbd494b7b 打包上传的2.0版本 2026-01-09 12:48:50 +08:00
ca895af384 登录系统以及超级管理员权限 2026-01-09 09:47:27 +08:00
e67edec876 添加登录系统以及超级管理员内容 2026-01-09 09:44:40 +08:00
4f970967e9 2代版本基本全部实现 2026-01-08 17:41:56 +08:00
f527faa06e 首页部分 2026-01-08 15:16:36 +08:00
29b48f6ba4 首页页面基本实现 2026-01-08 15:15:05 +08:00
a8984a156c 修改数据获取,确保json文件完整获取 2026-01-08 14:26:34 +08:00
a5b0b71d26 分步式页面布局,首页页面设计实现初稿 2026-01-08 13:53:19 +08:00
af4b4a28c3 打包上传云端版本 2026-01-07 17:36:33 +08:00
3099427eb6 云端上传版本 2026-01-07 17:35:07 +08:00
ef440177b3 test 2026-01-07 15:57:34 +08:00
15d66d6694 修复屏蔽设备恢复效果,设定后端计时器每天10点刷新,同时设定前端刷新页面时间 2026-01-07 13:14:41 +08:00
cbe6e884b5 两个网站图像展示,调试成功版 2026-01-07 11:28:09 +08:00
fa66da3ff5 106网站图像展示,未完成版 2026-01-07 11:27:20 +08:00
2c2f9e43e3 106网站图像展示,未完成版 2026-01-06 17:35:18 +08:00
45c3d602c0 修复两个网站json展示问题 2026-01-06 16:15:08 +08:00
e36b68da2e 82网页数据存储以及屏蔽设备 2026-01-06 15:37:36 +08:00
19e34ec065 82网页数据存储以及屏蔽设备 2026-01-06 15:37:21 +08:00
e9c9c60b27 页面部分实现 2026-01-06 15:36:42 +08:00
db2c040e5b 添加屏蔽设备以及82网站数据展示 2026-01-06 15:33:55 +08:00
49 changed files with 3506 additions and 1385 deletions

12
.idea/ZDXX.iml generated Normal file
View 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>

View File

@ -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()

View File

@ -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.

View File

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

View File

@ -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()

View File

@ -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
View 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
View 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
View 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
View 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("操作已取消。")

Binary file not shown.

BIN
2_3banben/instance/users.db Normal file

Binary file not shown.

126
2_3banben/models.py Normal file
View 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)

View File

@ -0,0 +1,2 @@
# routes/__init__.py
# 这是一个空文件,用于将 routes 文件夹标识为 Python 包。

465
2_3banben/routes/api.py Normal file
View 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
View 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')

View File

@ -0,0 +1,2 @@
# services/__init__.py
# 这是一个空文件,用于将 services 文件夹标识为 Python 包。

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

View 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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 不变
}
}
}
})

View File

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

View File

@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
web_dist
dist-ssr
*.local

View File

@ -10,9 +10,11 @@
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.1",
"echarts": "^6.0.0",
"element-plus": "^2.3.14",
"vue": "^3.3.4",
"vue-json-viewer": "^3.0.4"
"vue-json-viewer": "^3.0.4",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "4.5.0",
@ -543,6 +545,11 @@
"@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": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
@ -760,6 +767,15 @@
"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": {
"version": "2.13.0",
"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",
"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": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
@ -1262,6 +1283,28 @@
"peerDependencies": {
"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": {
@ -1560,6 +1603,11 @@
"@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": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
@ -1719,6 +1767,15 @@
"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": {
"version": "2.13.0",
"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",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
},
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"vite": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
@ -2030,6 +2092,22 @@
"requires": {
"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"
}
}
}
}

View File

@ -8,14 +8,16 @@
"build": "vite build"
},
"dependencies": {
"vue": "^3.3.4",
"element-plus": "^2.3.14",
"axios": "^1.5.1",
"@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": {
"vite": "4.5.0",
"@vitejs/plugin-vue": "4.5.0"
"@vitejs/plugin-vue": "4.5.0",
"vite": "4.5.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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

View File

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

View File

@ -1,5 +1,7 @@
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
@ -7,11 +9,10 @@ import JsonViewer from 'vue-json-viewer'
const app = createApp(App)
// 注册所有图标
app.use(router)
app.use(ElementPlus)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(JsonViewer)
app.mount('#app')

View 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

View 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

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

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

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

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

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

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