17 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
41 changed files with 3411 additions and 1650 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()

View File

@ -1,327 +0,0 @@
import os
import sys
import json
import threading
import requests
import logging
from datetime import datetime
from flask import Flask, jsonify, send_from_directory
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
from flask_apscheduler import APScheduler
from lxml import etree
# --- 配置日志 ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- 关键路径处理函数 (适配 PyInstaller) ---
def get_base_path():
"""获取运行时及其所在目录适配开发环境和打包后的EXE环境"""
if getattr(sys, 'frozen', False):
# 如果是打包后的 exesys.executable 是 exe 的路径
return os.path.dirname(sys.executable)
# 开发环境下,是当前脚本的路径
return os.path.dirname(os.path.abspath(__file__))
def get_static_path():
"""获取 Vue 静态资源 dist 的路径"""
if getattr(sys, 'frozen', False):
# PyInstaller 打包时,资源文件会被解压到 sys._MEIPASS 临时目录
# 我们需要在打包命令中指定 --add-data "dist;dist"
return os.path.join(sys._MEIPASS, 'dist')
# 开发环境
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist')
# --- Flask 初始化 ---
# static_folder 指向 Vue 打包后的 dist 目录
# static_url_path='' 表示静态文件不需要 /static 前缀
dist_folder = get_static_path()
app = Flask(__name__, static_folder=dist_folder, static_url_path='')
CORS(app)
# --- 数据库配置 ---
# 确保数据库生成在 exe 同级目录下,而不是临时文件夹中
db_path = os.path.join(get_base_path(), 'monitor_data.db')
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SCHEDULER_API_ENABLED'] = True
db = SQLAlchemy(app)
scheduler = APScheduler()
# --- 模型定义 (保持不变) ---
class MonitorRecord(db.Model):
id = db.Column(db.Integer, primary_key=True)
source = db.Column(db.String(50))
name = db.Column(db.String(100))
status = db.Column(db.String(50))
reason = db.Column(db.String(255))
offset = db.Column(db.String(50))
latest_time = db.Column(db.String(50))
check_time = db.Column(db.String(50))
content = db.Column(db.Text, nullable=True)
with app.app_context():
db.create_all()
# --- 爬虫配置 (保持不变) ---
CONFIG = {
"106": {
"base_url": "http://106.75.72.40:7500/api/proxy/tcp",
"primary_auth": "Basic YWRtaW46bGljYWhr",
"login_payload": {"username": "admin", "password": "licahk", "recaptcha": ""}
},
"82": {
"base_url": "http://82.156.1.111/weather/php",
"login": {'username': 'renlixin', 'password': 'licahk', 'login': '123'}
}
}
is_running = False
# --- 核心辅助函数 (保持不变) ---
def calculate_offset(latest_time_str):
if not latest_time_str or latest_time_str == "N/A":
return "从未同步"
try:
clean_date_str = str(latest_time_str).split()[0].replace('_', '-')
target_date = datetime.strptime(clean_date_str, "%Y-%m-%d").date()
diff = (datetime.now().date() - target_date).days
if diff == 0: return "当天已同步"
return f"滞后 {diff}"
except:
return "时间解析失败"
def save_record(source, name, status, reason, latest_time="N/A", content=None):
record = MonitorRecord.query.filter_by(source=source, name=name).first()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
current_offset = calculate_offset(latest_time)
if record:
if content is not None: record.content = content
if latest_time != "N/A": record.latest_time = latest_time
record.status = status
record.reason = reason
record.check_time = now_str
time_base = latest_time if latest_time != "N/A" else record.latest_time
record.offset = calculate_offset(time_base)
else:
new_record = MonitorRecord(
source=source, name=name, status=status, reason=reason,
offset=current_offset, latest_time=latest_time,
check_time=now_str, content=content
)
db.session.add(new_record)
try:
db.session.commit()
except Exception as e:
db.session.rollback()
logging.error(f"DB Error: {e}")
return f"{source}_{name}"
# --- 业务逻辑函数 (保持不变) ---
def get_106_dynamic_token(port):
try:
login_url = f"http://106.75.72.40:{port}/api/login"
resp = requests.post(login_url, json=CONFIG["106"]["login_payload"], timeout=10)
return resp.text.strip().replace('"', '') if resp.status_code == 200 else None
except:
return None
def find_closest_item(items, is_date_level=True):
if not items or not isinstance(items, list): return None
today = datetime.now()
scored_items = []
for item in items:
name_val = item.get('name', '')
path_val = item.get('path', '')
target_str = name_val if name_val else path_val.split('/')[-1]
try:
if is_date_level:
current_date = datetime.strptime(target_str, "%Y_%m_%d")
else:
mod_str = item.get('modified', '')
current_date = datetime.fromisoformat(mod_str.replace('Z', '+00:00'))
diff = abs((today - current_date.replace(tzinfo=None)).total_seconds())
scored_items.append((diff, item, target_str))
except:
continue
if not scored_items: return None
scored_items.sort(key=lambda x: x[0])
return scored_items[0]
def run_106_logic(active_set):
# (保持原样,省略以节省空间,直接用你原本的逻辑即可)
c = CONFIG["106"]
today_str = datetime.now().strftime("%Y_%m_%d")
main_headers = {"Authorization": c["primary_auth"], "User-Agent": "Mozilla/5.0"}
try:
resp = requests.get(c["base_url"], headers=main_headers, timeout=20)
proxies = resp.json().get('proxies', [])
for item in proxies:
name = item.get('name', '')
if not name.lower().endswith('_data'): continue
if "TOWER" not in name.upper(): continue
if str(item.get('status')).lower() != 'online':
key = save_record("106网站", name, "离线", f"设备状态: {item.get('status')}")
active_set.add(key)
continue
try:
port = item.get('conf', {}).get('remote_port')
token = get_106_dynamic_token(port)
if not token:
key = save_record("106网站", name, "异常", "Token获取失败")
active_set.add(key)
continue
headers = {"Authorization": c["primary_auth"], "x-auth": token}
api_root = "/api/resources/Data/" if "TOWER_" in name.upper() else "/api/resources/data/"
res1 = requests.get(f"http://106.75.72.40:{port}{api_root}", headers=headers, timeout=10)
best_date = find_closest_item(res1.json().get('items', []), True)
if not best_date or best_date[2] != today_str:
key = save_record("106网站", name, "正常", "未找到今日文件夹",
latest_time=best_date[2] if best_date else "N/A")
active_set.add(key)
continue
date_path = f"{api_root}{best_date[2]}/"
res2 = requests.get(f"http://106.75.72.40:{port}{date_path}", headers=headers, timeout=10)
best_file = find_closest_item(res2.json().get('items', []), False)
if not best_file:
key = save_record("106网站", name, "正常", "今日文件夹为空", latest_time=today_str)
active_set.add(key)
continue
file_item = best_file[1]
full_path = file_item.get('path') or f"{date_path}{file_item.get('name')}"
is_tower_i = "TOWER" in name.upper() and "TOWER_" not in name.upper()
if is_tower_i:
download_url = f"http://106.75.72.40:{port}/api/raw{full_path}"
res3 = requests.get(download_url, headers=headers, timeout=20)
final_content = f"Binary Data Size: {len(res3.content)}"
else:
file_api_url = f"http://106.75.72.40:{port}/api/resources{full_path}"
res3 = requests.get(file_api_url, headers=headers, timeout=20)
final_content = res3.json().get('content', '')
key = save_record("106网站", name, "正常", "同步成功", latest_time=today_str, content=final_content)
active_set.add(key)
except Exception as e:
key = save_record("106网站", name, "异常", f"采集错误: {str(e)[:50]}")
active_set.add(key)
except Exception as e:
logging.error(f"106 Global Error: {e}")
def run_82_logic(active_set):
# (保持原样,直接用你原本的逻辑即可)
c = CONFIG["82"]
session = requests.Session()
try:
session.post(f"{c['base_url']}/login.php", data=c["login"], timeout=10)
resp = session.post(f"{c['base_url']}/GetStationList.php", timeout=10)
stations = etree.HTML(resp.content).xpath('//option/@value')
for sid in [s for s in stations if s]:
try:
r = session.post(f"{c['base_url']}/getLastWeatherData.php", data=str(sid),
headers={'Content-Type': 'text/plain'}, timeout=10)
data = r.json()
if data:
d_list = data.get('date', [])
latest = str(d_list[-1]) if d_list else "N/A"
key = save_record("82网站", sid, "正常", "同步成功", latest_time=latest,
content=json.dumps(data, ensure_ascii=False))
active_set.add(key)
else:
key = save_record("82网站", sid, "异常", "返回空数据")
active_set.add(key)
except:
key = save_record("82网站", sid, "异常", "单个采集失败")
active_set.add(key)
except Exception as e:
logging.error(f"82 Global Error: {e}")
def execute_monitor_task():
global is_running
if is_running: return
is_running = True
logging.info("Starting monitor task...")
with app.app_context():
active_set = set()
run_106_logic(active_set)
run_82_logic(active_set)
all_records = MonitorRecord.query.all()
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for record in all_records:
if f"{record.source}_{record.name}" not in active_set:
record.status = "已离线"
record.reason = "设备本次未出现"
record.check_time = now_str
record.offset = calculate_offset(record.latest_time)
try:
db.session.commit()
except:
db.session.rollback()
is_running = False
logging.info("Monitor task finished.")
# --- API 路由 (保持不变) ---
@app.route('/api/run', methods=['POST'])
def manual_start():
if is_running: return jsonify({"status": "busy"}), 400
threading.Thread(target=execute_monitor_task).start()
return jsonify({"status": "started"})
@app.route('/api/status')
def status(): return jsonify({"is_running": is_running})
@app.route('/api/logs')
def logs():
data = MonitorRecord.query.all()
return jsonify([{
"source": l.source, "name": l.name, "status": l.status,
"reason": l.reason, "offset": l.offset, "latest_time": l.latest_time,
"check_time": l.check_time, "content": l.content
} for l in data])
# --- 新增: 前端页面托管路由 ---
@app.route('/')
def serve_index():
return send_from_directory(app.static_folder, 'index.html')
@app.route('/<path:path>')
def serve_static_files(path):
# 尝试在 dist 目录寻找文件 (css, js, icons)
file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path):
return send_from_directory(app.static_folder, path)
# 如果找不到文件例如刷新页面时的路由返回index.html让Vue Router处理
return send_from_directory(app.static_folder, 'index.html')
# --- 调度器与启动 ---
@scheduler.task('cron', id='daily_job', hour=10, minute=0)
def auto_run_task():
with app.app_context():
threading.Thread(target=execute_monitor_task).start()
if __name__ == "__main__":
scheduler.init_app(app)
scheduler.start()
# Host='0.0.0.0' 允许外部IP访问
# Port=5000 (确保 Windows 防火墙开放了此端口)
print("应用正在启动... 请确保 dist 文件夹与脚本/exe 同级或已被打包")
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)

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

@ -13,7 +13,8 @@
"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",
@ -544,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",
@ -1278,6 +1284,20 @@
"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",
@ -1583,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",
@ -2068,6 +2093,14 @@
"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",

View File

@ -13,7 +13,8 @@
"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",

View File

@ -1,476 +1,59 @@
<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> 系统就绪 (最后更新: {{ lastCheckTime }})
</span>
</div>
</div>
<div class="header-actions">
<el-button type="primary" :loading="isRunning" @click="handleManualRefresh(false)" round icon="Refresh" :size="isMobile ? 'small' : 'default'">手动同步</el-button>
</div>
</div>
</template>
<div class="app-container">
<main class="main-content">
<router-view></router-view>
</main>
<div class="status-summary">
<el-tag type="danger" effect="dark" class="res-tag">红色已离线 / 异常 / 滞后>7</el-tag>
<el-tag type="warning" color="#ff8c00" effect="dark" class="res-tag" style="border-color: #ff8c00;">橘色滞后 2-7 </el-tag>
<el-tag type="warning" effect="dark" class="res-tag">黄色滞后 1-2 </el-tag>
<el-tag type="success" effect="dark" class="res-tag">绿色正常且今日已同步</el-tag>
</div>
<div class="toolbar" :class="{ 'mobile-toolbar': isMobile }">
<div class="filter-section">
<el-radio-group v-model="filters.site" :size="isMobile ? 'small' : 'default'">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="106">106 塔上光谱仪</el-radio-button>
<el-radio-button value="82">82 高光谱传感器</el-radio-button>
</el-radio-group>
<el-input
v-model="filters.keyword"
placeholder="搜索设备名称..."
class="search-input"
clearable
/>
</div>
<div class="action-section">
<el-checkbox v-model="showHidden" label="显示屏蔽" border style="margin-right: 10px" :size="isMobile ? 'small' : 'default'"/>
<el-button type="warning" plain :disabled="selectedRows.length === 0" @click="hideSelected" :size="isMobile ? 'small' : 'default'">屏蔽选中</el-button>
</div>
</div>
<el-table
ref="multipleTableRef"
:data="sortedData"
border
height="600"
v-loading="isRunning"
@selection-change="val => selectedRows = val"
:row-class-name="tableRowClassName"
style="width: 100%"
>
<el-table-column type="selection" width="40" align="center" fixed="left" />
<el-table-column label="状态" :width="isMobile ? 90 : 120" align="center">
<template #default="{ row }">
<el-tag :style="getStatusTagStyle(row)" effect="dark" size="small">{{ getStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="名称" min-width="180">
<template #default="{ row }">
<div class="name-cell">
<el-link type="primary" underline="hover" @click="showDetails(row)" style="font-weight: bold; font-size: 14px;">
{{ formatDisplayName(row.name) }}
</el-link>
<el-tag v-if="isHidden(row.name)" type="info" size="small" style="margin-left:5px">隐藏</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="reason" label="反馈" min-width="150" v-if="!isMobile">
<template #default="{ row }">
<span :style="{ color: getStatusColor(row), fontWeight: 'bold' }">{{ formatReason(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="offset" label="时效" width="80" align="center" v-if="!isMobile"/>
<el-table-column prop="latest_time" label="数据时间" width="170" align="center" />
<el-table-column label="操作" width="70" v-if="showHidden" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="isHidden(row.name)" type="primary" link @click="restoreDevice(row.name)">恢复</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="drawerVisible"
title="设备详情"
:size="isMobile ? '100%' : '80%'"
@opened="initCharts"
direction="rtl"
>
<!-- <div v-if="activeDevice" class="drawer-content">-->
<!-- <div class="info-banner">-->
<!-- <el-descriptions :column="isMobile ? 1 : 4" border size="small">-->
<!-- <el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item>-->
<!-- <el-descriptions-item label="当前状态">-->
<!-- <el-tag size="small" :style="getStatusTagStyle(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>-->
<!-- </el-descriptions-item>-->
<!-- <el-descriptions-item label="数据时间">{{ activeDevice.latest_time }}</el-descriptions-item>-->
<!-- <el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>-->
<!-- </el-descriptions>-->
<!-- </div>-->
<!-- <div class="visual-section">-->
<!-- <h3 class="section-title">-->
<!-- <el-icon><DataLine /></el-icon>-->
<!-- {{ is106Site ? '光谱能量分布 (完整原始数据)' : '高光谱传感器数据 (Up/Down Spec)' }}-->
<!-- </h3>-->
<!-- <div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div>-->
<!-- <div v-for="(module, index) in currentChartModules" :key="index" class="chart-container">-->
<!-- <div class="chart-header" v-if="is106Site">-->
<!-- <div class="tag-group">-->
<!-- <span class="module-tag">型号: {{ module.model }}</span>-->
<!-- <span class="sn-tag">SN: {{ module.sn }}</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<sidevueold></sidevueold>
</el-drawer>
<footer class="version-footer">
2.2版本(权限管理版) © 2026 Device Monitor
</footer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import axios from 'axios'
import * as echarts from 'echarts'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Loading, CircleCheck, Refresh, DataLine } from '@element-plus/icons-vue'
import sidevueold from "./sidevueold.vue"
// --- 响应式布局状态 ---
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 768)
// 窗口大小监听函数
const handleResize = () => {
windowWidth.value = window.innerWidth
// 触发图表重绘
chartInstances.forEach(chart => chart && chart.resize())
}
// --- 状态变量 ---
const rawData = ref([])
const isRunning = ref(false)
const lastCheckTime = ref('N/A')
const selectedRows = ref([])
const showHidden = ref(false)
const drawerVisible = ref(false)
const activeDevice = ref(null)
const filters = reactive({ site: 'all', keyword: '' })
const multipleTableRef = ref()
let chartInstances = [] // 存储图表实例以便resize
// 初始化隐藏列表
const ignoredList = ref(JSON.parse(localStorage.getItem('hide_list') || '[]'))
let autoRefreshTimer = null
// --- 工具函数 ---
const formatSource = (source) => {
if (!source) return ''
const s = source.toString()
if (s.includes('106')) return '106 塔上光谱仪'
if (s.includes('82')) return '82 高光谱传感器'
return s
}
const formatDisplayName = (name) => {
if (!name) return ''
return name.split('_').map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join('_')
}
const parseFlexibleDate = (dateStr) => {
if (!dateStr || dateStr === 'N/A') return null
try {
let cleanStr = dateStr.toString().split('.')[0].replace(/[_/]/g, '-')
const d = new Date(cleanStr)
return isNaN(d.getTime()) ? null : d
} catch { return null }
}
const getHoursDiff = (dateStr) => {
if (!dateStr || dateStr === 'N/A') return 999
const lastDate = parseFlexibleDate(dateStr)
if (!lastDate) return 999
return (new Date() - lastDate) / (1000 * 3600)
}
// --- 业务逻辑 (状态与排序) ---
const getLevel = (row) => {
if (row.status === '已离线' || row.status === '异常') return 4
if (!row.content || row.content === '{}') return 4
const last = parseFlexibleDate(row.latest_time)
if (!last) return 4
const now = new Date()
const days = (now - last) / (1000 * 3600 * 24)
if (days > 7) return 4
if (days > 2) return 3
if (now.getDate() !== last.getDate()) return 2
return 1
}
const getStatusLabel = (row) => {
if (row.status === '已离线') return '已离线'
if (row.status === '异常') return '异常'
const level = getLevel(row)
if (level === 4) return '缺失'
if (level === 3) return '>2天'
if (level === 2) return '昨日'
return '在线'
}
const getStatusColor = (row) => {
const level = getLevel(row)
return ['#909399', '#67C23A', '#E6A23C', '#ff8c00', '#F56C6C'][level]
}
const getStatusTagStyle = (row) => {
const color = getStatusColor(row)
return { backgroundColor: color, borderColor: color, color: 'white', border: 'none' }
}
const formatReason = (row) => {
if (row.reason && row.reason !== '同步成功') return row.reason
const level = getLevel(row)
if (level === 2) return '⚠️ 待今日更新'
return '✅ 同步正常'
}
// --- 隐藏/恢复逻辑 ---
const isHidden = (name) => ignoredList.value.includes(name)
const restoreDevice = (name) => {
if (!name) return
ignoredList.value = ignoredList.value.filter(item => item !== name)
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.success('设备已恢复显示')
}
const hideSelected = () => {
if (selectedRows.value.length === 0) return
const namesToHide = selectedRows.value.map(row => row.name)
let count = 0
namesToHide.forEach(name => {
if (!ignoredList.value.includes(name)) {
ignoredList.value.push(name)
count++
}
})
if (count > 0) {
localStorage.setItem('hide_list', JSON.stringify(ignoredList.value))
ElMessage.warning(`已屏蔽 ${count} 个设备`)
if (multipleTableRef.value) multipleTableRef.value.clearSelection()
} else {
ElMessage.info('选中的设备已在屏蔽列表中')
}
}
const showDetails = (row) => {
activeDevice.value = row
drawerVisible.value = true
}
// --- 过滤与排序 ---
const sortedData = computed(() => {
return rawData.value.filter(d => {
const basicMatch = (filters.site === 'all' || d.source.includes(filters.site)) &&
d.name.toLowerCase().includes(filters.keyword.toLowerCase())
if (showHidden.value) return basicMatch
else return basicMatch && !isHidden(d.name)
}).sort((a, b) => getLevel(b) - getLevel(a))
})
const tableRowClassName = ({ row }) => row.status === '已离线' ? 'offline-row' : ''
// --- 刷新逻辑 ---
const fetchLogs = async () => {
try {
const res = await axios.get('/api/logs')
rawData.value = res.data
if (res.data.length > 0) {
const latest = res.data.reduce((prev, curr) => (prev.check_time > curr.check_time) ? prev : curr)
lastCheckTime.value = latest.check_time
}
} catch (e) {
console.warn("API Error, using mock data for display")
}
}
const checkStatus = async () => {
try {
const res = await axios.get('/api/status')
isRunning.value = res.data.is_running
if (isRunning.value) setTimeout(checkStatus, 2000)
else fetchLogs()
} catch { isRunning.value = false }
}
const handleManualRefresh = async (force = false) => {
const hours = getHoursDiff(lastCheckTime.value)
if (!force && hours < 6) {
try {
await ElMessageBox.confirm(
`数据更新于 ${hours.toFixed(1)} 小时前。后端每日10点自动更新通常无需手动操作。\n是否强制重新爬取`,
'数据尚新', { confirmButtonText: '强制爬取', cancelButtonText: '仅加载最新', type: 'warning' }
)
} catch {
fetchLogs()
ElMessage.success('已加载最新数据库记录')
return
}
}
try {
isRunning.value = true
await axios.post('/api/run')
checkStatus()
ElMessage.success('任务已下发')
} catch {
isRunning.value = false
ElMessage.warning('后台已有任务在运行')
}
}
// --- 图表逻辑 ---
const is106Site = computed(() => activeDevice.value?.source?.includes('106'))
const currentChartModules = computed(() => {
if (!activeDevice.value?.content || activeDevice.value.content === '{}') return []
if (is106Site.value) {
const modules = []
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let match
const contentStr = activeDevice.value.content
while ((match = infoRegex.exec(contentStr)) !== null) {
const wavelengths = match[3].split(',').map(Number).filter(n => !isNaN(n))
const series = []
for (let p = 1; p <= 4; p++) {
const dMatch = contentStr.match(new RegExp(`${match[1].trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i'))
if (dMatch) {
const vals = dMatch[1].split(',').map(v => {
const n = parseFloat(v);
// 修改点 2不再判断 n > 65500直接返回原始值 n
return n;
})
if (vals.some(v => v !== null)) series.push({ name: `P${p}`, data: vals, color: ['#5470c6', '#91cc75', '#fac858', '#ee6666'][p-1] })
}
}
if (series.length) modules.push({ model: match[1], sn: match[2], xAxis: wavelengths, series })
}
return modules
} else {
try {
const d = JSON.parse(activeDevice.value.content)
return d.wavelenth ? [{ title: activeDevice.value.name, xAxis: d.wavelenth, series: [
{ name: 'DownSpec', data: d.downspec, color: '#409EFF' }, { name: 'UpSpec', data: d.upspec, color: '#67C23A' }
]}] : []
} catch { return [] }
}
})
const initCharts = () => {
chartInstances = [] // 清空旧实例引用
nextTick(() => {
currentChartModules.value.forEach((m, i) => {
const dom = document.getElementById(`chart-${i}`)
if (dom) {
if (echarts.getInstanceByDom(dom)) echarts.getInstanceByDom(dom).dispose()
const chart = echarts.init(dom)
chartInstances.push(chart)
chart.setOption({
title: { text: is106Site.value ? `SN: ${m.sn}` : m.title, left: 'center', top: 10, textStyle: { fontSize: isMobile.value ? 14 : 18 } },
tooltip: { trigger: 'axis', confine: true },
legend: { top: 35, type: 'scroll' },
grid: { top: 70, bottom: 30, right: isMobile.value ? 10 : 30, left: isMobile.value ? 40 : 50 },
xAxis: { type: 'category', data: m.xAxis, boundaryGap: false },
yAxis: { type: 'value', min: 'dataMin', max: 'dataMax' },
series: m.series.map(s => ({
name: s.name,
type: 'line',
data: s.data,
connectNulls: false,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: s.color },
areaStyle: { opacity: 0.1, color: s.color }
}))
})
}
})
})
}
// --- 生命周期 ---
onMounted(() => {
document.title = "光谱数据监控"
fetchLogs()
window.addEventListener('resize', handleResize)
autoRefreshTimer = setInterval(() => {
if (!isRunning.value) fetchLogs()
}, 300000)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (autoRefreshTimer) clearInterval(autoRefreshTimer)
chartInstances.forEach(c => c && c.dispose())
})
// App.vue 保持简洁
</script>
<style scoped>
/* 基础 PC 端样式 */
.container { padding: 20px; max-width: 1400px; margin: 0 auto; background-color: #f5f7fa; min-height: 100vh; transition: all 0.3s; }
.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.sys-title { margin: 0; font-size: 22px; color: #303133; font-weight: 600; }
.sys-status { font-size: 13px; color: #909399; margin-top: 5px; }
.status-running { color: #409EFF; font-weight: bold; display: flex; align-items: center; gap: 5px; }
.status-idle { display: flex; align-items: center; gap: 5px; }
.status-summary { margin: 15px 0; display: flex; gap: 10px; flex-wrap: wrap; }
.toolbar { background: #fff; padding: 15px 20px; border-radius: 8px; display: flex; justify-content: space-between; margin-bottom: 20px; border: 1px solid #ebeef5; align-items: center; transition: all 0.3s; }
.filter-section { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.name-cell { display: flex; align-items: center; flex-wrap: wrap; gap: 5px;}
:deep(.offline-row) { background-color: #fef0f0 !important; }
.drawer-content { padding: 0 20px 20px; }
.info-banner { margin-bottom: 20px; }
.chart-container { margin-bottom: 30px; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; background: #fff; }
.chart-header { background: #fafafa; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e4e7ed; }
.echart-box { width: 100%; height: 380px; }
.echart-box.no-header { margin-top: 15px; }
/* 移动端适配 (Screen < 768px) */
@media screen and (max-width: 768px) {
.container { padding: 10px; }
/* 头部调整 */
.header-row { flex-direction: column; align-items: flex-start; }
.left-panel { width: 100%; margin-bottom: 10px; }
.header-actions { width: 100%; display: flex; justify-content: flex-end; }
.sys-title { font-size: 18px; }
/* 状态标签调整 */
.status-summary { gap: 5px; }
.res-tag { font-size: 11px; height: 24px; padding: 0 5px; }
/* 工具栏调整 */
.mobile-toolbar { flex-direction: column; align-items: stretch; padding: 15px 10px; }
.filter-section { flex-direction: column; align-items: stretch; width: 100%; }
.search-input { width: 100% !important; margin-left: 0 !important; margin-top: 5px; }
.action-section {
display: flex;
justify-content: space-between;
margin-top: 15px;
padding-top: 10px;
border-top: 1px dashed #ebeef5;
<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 橡皮筋效果 */
}
/* Drawer 内部调整 */
.drawer-content { padding: 0 10px 20px; }
.chart-header { flex-direction: column; align-items: flex-start; gap: 5px; }
.echart-box { height: 300px; }
#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

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

@ -1,39 +0,0 @@
<script setup>
import {DataLine} from "@element-plus/icons-vue";
</script>
<template>
<div v-if="activeDevice" class="drawer-content">
<div class="info-banner">
<el-descriptions :column="isMobile ? 1 : 4" border size="small">
<el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag size="small" :style="getStatusTagStyle(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据时间">{{ activeDevice.latest_time }}</el-descriptions-item>
<el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="visual-section">
<h3 class="section-title">
<el-icon><DataLine /></el-icon>
{{ is106Site ? '光谱能量分布 (完整原始数据)' : '高光谱传感器数据 (Up/Down Spec)' }}
</h3>
<div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div>
<div v-for="(module, index) in currentChartModules" :key="index" class="chart-container">
<div class="chart-header" v-if="is106Site">
<div class="tag-group">
<span class="module-tag">型号: {{ module.model }}</span>
<span class="sn-tag">SN: {{ module.sn }}</span>
</div>
</div>
<div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@ -1,60 +0,0 @@
<script>
import {DataLine} from "@element-plus/icons-vue";
export default {
name: "sidevueold",
components: {DataLine},
data(){
return{
activeDevice:{},
}
},
mounted() {
console.log("hello from 111")
},
methods:{
},
unmounted() {
}
}
</script>
<template>
<!-- <div v-if="activeDevice" class="drawer-content">-->
<!-- <div class="info-banner">-->
<!-- <el-descriptions :column="isMobile ? 1 : 4" border size="small">-->
<!-- <el-descriptions-item label="设备名称">{{ formatDisplayName(activeDevice.name) }}</el-descriptions-item>-->
<!-- <el-descriptions-item label="当前状态">-->
<!-- <el-tag size="small" :style="getStatusTagStyle(activeDevice)">{{ getStatusLabel(activeDevice) }}</el-tag>-->
<!-- </el-descriptions-item>-->
<!-- <el-descriptions-item label="数据时间">{{ activeDevice.latest_time }}</el-descriptions-item>-->
<!-- <el-descriptions-item label="检查时间">{{ activeDevice.check_time }}</el-descriptions-item>-->
<!-- </el-descriptions>-->
<!-- </div>-->
<!-- <div class="visual-section">-->
<!-- <h3 class="section-title">-->
<!-- <el-icon><DataLine /></el-icon>-->
<!-- {{ is106Site ? '光谱能量分布 (完整原始数据)' : '高光谱传感器数据 (Up/Down Spec)' }}-->
<!-- </h3>-->
<!-- <div v-if="currentChartModules.length === 0" class="empty-hint"><el-empty description="暂无有效的图表数据" /></div>-->
<!-- <div v-for="(module, index) in currentChartModules" :key="index" class="chart-container">-->
<!-- <div class="chart-header" v-if="is106Site">-->
<!-- <div class="tag-group">-->
<!-- <span class="module-tag">型号: {{ module.model }}</span>-->
<!-- <span class="sn-tag">SN: {{ module.sn }}</span>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div :id="'chart-' + index" class="echart-box" :class="{ 'no-header': !is106Site }"></div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
aaaaaa
</template>
<style scoped>
</style>

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>