diff --git a/2_3banben/app.py b/2_3banben/app.py index 228d884..9d3a766 100644 --- a/2_3banben/app.py +++ b/2_3banben/app.py @@ -1,92 +1,55 @@ import os import sys import json -import logging import mimetypes from datetime import datetime from flask import Flask, send_from_directory, jsonify from flask_cors import CORS -from flask_apscheduler import APScheduler -# ============================================================================== -# ✅ 1. 核心模块引用 -# ============================================================================== +# 引入配置 +from config import Config +# 引入扩展 +from extensions import db, jwt, scheduler +# 引入模型 (确保 create_all 能扫描到) +from models import Device, DeviceHistory, User + +# 引入 API 蓝图和工具 try: - # 数据库实例 (在根目录 extensions.py 中) - from extensions import db + from routes.api import api_bp, calculate_offset +except ImportError: + api_bp = None + calculate_offset = None - # 数据模型 (在根目录 models.py 中) - from models import Device, DeviceHistory, MaintenanceLog - - # 核心业务逻辑 (在 services/core.py 中) +# 引入爬虫服务 +try: from services.core import execute_monitor_task +except ImportError: + execute_monitor_task = None - # 路由蓝图 (在 routes/api.py 中) - try: - from routes.api import api_bp as device_bp - except ImportError: - from routes.api import device_bp - - # 工具函数 (在 routes/api.py 中) - from routes.api import calculate_offset - -except ImportError as e: - print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}") - print(f"系统路径: {sys.path}") - sys.exit(1) - - -# ============================================================================== -# 2. 路径计算模块 (兼容 PyInstaller 打包) -# ============================================================================== -def get_base_path(): - """获取运行时基准路径,兼容开发环境和打包环境""" - if getattr(sys, 'frozen', False): - if hasattr(sys, '_MEIPASS'): - return sys._MEIPASS # --onefile 模式 - else: - return os.path.dirname(os.path.abspath(sys.executable)) # --onedir 模式 - else: - return os.path.abspath(os.path.dirname(__file__)) - - -BASE_DIR = get_base_path() -STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist') -INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance') -DB_PATH = os.path.join(INSTANCE_FOLDER, 'devices.db') - -# 修复 Windows 下注册表 MIME 类型缺失导致网页白屏的问题 +# 注册 MIME 类型 (防止前端 JS/CSS 加载报 404 或类型错误) mimetypes.add_type('application/javascript', '.js') mimetypes.add_type('text/css', '.css') -print(f"🚀 运行环境: {'Packaged' if getattr(sys, 'frozen', False) else 'Dev'}") -print(f"📂 基准路径: {BASE_DIR}") -print(f"💾 数据库路径: {DB_PATH}") - -# ============================================================================== -# 3. 定时任务逻辑 -# ============================================================================== +# --- 定时任务逻辑 (保持不变) --- def auto_monitor_job(app): - """定时任务具体执行逻辑""" with app.app_context(): - print(f"⏰ [定时任务] 启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"⏰ [定时任务] 启动: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if not execute_monitor_task: - print("❌ 错误: 无法加载爬虫核心模块 (execute_monitor_task is Missing)") + print("❌ 错误: 爬虫模块未加载") return try: - # 执行爬取 task_result = execute_monitor_task() if not task_result: - print("⚠️ [定时任务] 爬虫未获取到数据") + 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 @@ -98,15 +61,17 @@ def auto_monitor_job(app): db.session.add(device) db.session.flush() - # 更新字段 + # 更新状态 device.status = item.get('status') device.current_value = item.get('value') device.latest_time = item.get('target_time') device.check_time = current_time device.json_data = json.dumps(item.get('raw_json', {}), ensure_ascii=False) - device.offset = calculate_offset(item.get('target_time')) - # 写入历史 + if calculate_offset: + device.offset = calculate_offset(item.get('target_time')) + + # 记录历史 db.session.add(DeviceHistory( device_id=device.id, status=device.status, @@ -117,86 +82,143 @@ def auto_monitor_job(app): count += 1 db.session.commit() - print(f"✅ [定时任务] 成功更新 {count} 台设备状态") + print(f"✅ [定时任务] 更新了 {count} 台设备") except Exception as e: db.session.rollback() print(f"❌ [定时任务] 异常: {str(e)}") -# ============================================================================== -# 4. Flask 应用工厂 -# ============================================================================== +# --- App 工厂 --- def create_app(): - # 🔴 关键修复:移除了 static_url_path='' - # 这样 Flask 就不会强制拦截所有根路径请求,让下面的 serve_static 有机会处理 /dashboard - app = Flask(__name__, static_folder=STATIC_FOLDER) + app = Flask(__name__, static_folder=Config.STATIC_FOLDER) - CORS(app) + # 1. 加载配置 (包含数据库、JWT、爬虫配置) + app.config.from_object(Config) - # 确保 instance 目录存在 - if not os.path.exists(INSTANCE_FOLDER): - os.makedirs(INSTANCE_FOLDER, exist_ok=True) - - app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}' - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['SCHEDULER_API_ENABLED'] = True - - # 初始化数据库 + # 2. 初始化扩展 db.init_app(app) - - # 初始化定时任务 - scheduler = APScheduler() + jwt.init_app(app) scheduler.init_app(app) - scheduler.start() - # 添加定时任务 (每天 10:00) - scheduler.add_job( - id='daily_monitor_task', - func=auto_monitor_job, - args=[app], - trigger='cron', - hour=10, - minute=0 - ) + # 3. 配置 CORS (允许 Authorization 头,解决 401/422 的关键) + # 允许所有来源,允许凭证,允许关键 Header + CORS(app, + resources={r"/*": {"origins": "*"}}, + supports_credentials=True, + allow_headers=["Content-Type", "Authorization", "X-Requested-With"]) - # 注册蓝图 - app.register_blueprint(device_bp) + # 4. 注册蓝图 + if api_bp: + app.register_blueprint(api_bp) - # ------------------------------------------------- - # 前端路由支持 (Vue History Mode) - # ------------------------------------------------- + # ========================================== + # 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 '\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=10, + 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_dist/index.html)", 404 + return "Web files not found. Please build frontend first.", 404 return send_from_directory(app.static_folder, 'index.html') @app.route('/') def serve_static(path): - # 1. 优先尝试直接返回实际存在的文件 (js, css, img等) file_path = os.path.join(app.static_folder, path) if os.path.exists(file_path): return send_from_directory(app.static_folder, path) - # 2. 如果是 API 请求但没找到对应接口,返回 404 JSON (不返回 HTML) - if path.startswith('api') or path.startswith('static'): - return jsonify({'code': 404, 'message': 'Not Found'}), 404 + # 如果不是静态文件请求,也不是 api 请求,就返回 index.html (前端路由) + if path.startswith('api'): + return jsonify({'code': 404, 'msg': 'API endpoint not found'}), 404 - # 3. 关键逻辑: - # 访问 /dashboard 等前端路由时,文件系统中并没有 dashboard 这个文件 - # 所以会走到这里,返回 index.html,让 Vue 及其 Router 接管页面渲染 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() - # 生产环境/打包环境通常设为 False + + # 判断是否为打包后的环境 debug_mode = not getattr(sys, 'frozen', False) - print("🚀 服务启动中...") + + 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) \ No newline at end of file diff --git a/2_3banben/config.py b/2_3banben/config.py index 8e9336e..0eea396 100644 --- a/2_3banben/config.py +++ b/2_3banben/config.py @@ -1,34 +1,51 @@ import os import sys +from datetime import timedelta def get_base_path(): """获取运行时路径 (兼容打包后的 exe 和开发环境)""" if getattr(sys, 'frozen', False): - return os.path.dirname(sys.executable) + 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__)) -def get_static_path(): - """获取 dist 静态资源路径""" - if getattr(sys, 'frozen', False): - return os.path.join(sys._MEIPASS, 'dist') - return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dist') - - class Config: BASE_DIR = get_base_path() + 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}' + } - # 数据库路径:保存在运行目录下,文件名为 monitor_data.db - # Windows 下路径需要注意转义,这里使用 os.path.join 最安全 - SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASE_DIR, "monitor_data.db")}' 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" # 👈 必须加这个,否则 APScheduler 可能报错 + SCHEDULER_TIMEZONE = "Asia/Shanghai" - # --- 爬虫配置 (Service层会读取这里) --- + # --- 爬虫配置 (保留你原有的配置) --- CRAWLER_CONFIG = { "106": { "base_url": "http://106.75.72.40:7500/api/proxy/tcp", diff --git a/2_3banben/extensions.py b/2_3banben/extensions.py index 21a81f5..d162b0b 100644 --- a/2_3banben/extensions.py +++ b/2_3banben/extensions.py @@ -1,9 +1,10 @@ -#extensions.py from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS from flask_apscheduler import APScheduler +from flask_jwt_extended import JWTManager # 这里只创建对象,不绑定 app db = SQLAlchemy() cors = CORS() -scheduler = APScheduler() \ No newline at end of file +scheduler = APScheduler() +jwt = JWTManager() \ No newline at end of file diff --git a/2_3banben/models.py b/2_3banben/models.py index 37695ee..6f0a501 100644 --- a/2_3banben/models.py +++ b/2_3banben/models.py @@ -1,9 +1,14 @@ from datetime import datetime -import json +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) @@ -18,13 +23,12 @@ class Device(db.Model): reason = db.Column(db.String(255)) offset = db.Column(db.String(50)) - # 手动录入字段(受保护,run_monitor 不主动覆盖) + # 手动录入字段 install_site = db.Column(db.String(100), default="") is_maintaining = db.Column(db.Boolean, default=False) is_hidden = db.Column(db.Boolean, default=False) def to_dict(self): - # 统一状态映射逻辑 api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online' return { 'id': self.id, @@ -52,7 +56,6 @@ class DeviceHistory(db.Model): result_data = db.Column(db.String(200), default="") json_data = db.Column(db.Text) file_path = db.Column(db.String(255)) - recorded_at = db.Column(db.DateTime, default=datetime.now) class MaintenanceLog(db.Model): @@ -72,4 +75,37 @@ class MaintenanceLog(db.Model): 'location': self.location or '', 'content': self.content, 'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S') - } \ No newline at end of file + } + +# ======================= +# 数据库 2: 用户管理 (users.db) +# ======================= + +class User(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)) + role = db.Column(db.String(20), default='client') # 'admin' or '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): + """ + 关联表:存储用户ID和设备ID的对应关系。 + 注意:因为跨数据库,这里不能使用 ForeignKey 约束指向 Device 表。 + 我们只存储纯整数 ID (device_id),逻辑关联在 api.py 中处理。 + """ + __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) # 对应 devices.db 中的 Device.id \ No newline at end of file diff --git a/2_3banben/routes/api.py b/2_3banben/routes/api.py index 6e9f6ee..9d4741a 100644 --- a/2_3banben/routes/api.py +++ b/2_3banben/routes/api.py @@ -1,12 +1,13 @@ -import os -import shutil import json import re from datetime import datetime from flask import Blueprint, jsonify, request from sqlalchemy import desc, or_ +# 引入 get_jwt_identity 用来获取当前用户ID +from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity + from extensions import db -from models import Device, DeviceHistory, MaintenanceLog +from models import Device, DeviceHistory, MaintenanceLog, User, UserDevicePermission # 尝试导入爬虫模块 try: @@ -18,157 +19,27 @@ api_bp = Blueprint('api', __name__, url_prefix='/api') # ======================= -# 0. 认证接口 +# 🔧 辅助函数 # ======================= +def is_admin(user_id): + """ + 判断用户权限 + 兼容 Token 中存储的 String 类型 ID + """ + # 1. 既然 Token 里是字符串,这里必须转成字符串比较,或者转成 int 比较 + if str(user_id) == '0': return True -@api_bp.route('/login', methods=['POST']) -def login(): - data = request.get_json() - username = data.get('username') - password = data.get('password') - - if username == 'admin' and password == 'licahk': - return jsonify({ - 'code': 200, - 'message': '登录成功', - 'token': 'super-admin-token-2026', - 'user': {'username': 'admin', 'role': 'administrator'} - }) - - return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401 - - -# ======================= -# 1. 设备概览与详情接口 -# ======================= - -@api_bp.route('/devices_overview', methods=['GET']) -def devices_overview(): - try: - devices = Device.query.all() - data_list = [d.to_dict() for d in devices] - return jsonify({'code': 200, 'data': data_list}) - except Exception as e: - return jsonify({'code': 500, 'message': str(e)}) - - -@api_bp.route('/device_data_by_date', methods=['GET']) -def device_data_by_date(): - name = request.args.get('name') - date_str = request.args.get('date') - - if not name or not date_str: - return jsonify({'code': 400, 'message': 'Missing name or date'}), 400 - - device = Device.query.filter_by(name=name).first() - if not device: - return jsonify({'code': 404, 'message': 'Device not found'}), 404 - - content = None - history_record = DeviceHistory.query.filter( - DeviceHistory.device_id == device.id, - DeviceHistory.data_time.like(f"{date_str}%") - ).order_by(desc(DeviceHistory.id)).first() - - if history_record: - content = history_record.json_data - elif device.latest_time and device.latest_time.startswith(date_str): - content = device.json_data - - if content: - return jsonify({ - 'code': 200, - 'name': device.name, - 'source': device.source, - 'content': content - }) - return jsonify({'code': 404, 'message': 'No data for this date'}), 404 - - -# ======================= -# 2. 维修日志接口 -# ======================= - -@api_bp.route('/logs/list', methods=['GET']) -def get_logs(): - keyword = request.args.get('keyword', '') - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - - query = MaintenanceLog.query - if keyword: - kw = f"%{keyword}%" - query = query.filter(or_( - MaintenanceLog.device_name.like(kw), - MaintenanceLog.engineer.like(kw), - MaintenanceLog.location.like(kw), - MaintenanceLog.content.like(kw) - )) - - if start_date and end_date: - try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59) - query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt)) - except ValueError: - pass - - logs = query.order_by(MaintenanceLog.timestamp.desc()).all() - return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]}) - - -@api_bp.route('/logs/add', methods=['POST']) -def add_log(): - data = request.get_json() - try: - new_log = MaintenanceLog( - device_name=data.get('device_name', '未知设备'), - engineer=data.get('engineer', ''), - location=data.get('location', ''), - content=data.get('content', '') - ) - db.session.add(new_log) - db.session.commit() - return jsonify({'code': 200, 'message': 'Log saved'}) - except Exception as e: - db.session.rollback() - return jsonify({'code': 500, 'message': str(e)}) - - -@api_bp.route('/logs/update', methods=['POST']) -def update_log(): - data = request.get_json() - log_id = data.get('id') - log = MaintenanceLog.query.get(log_id) - if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404 + if not user_id: return False try: - log.device_name = data.get('device_name', log.device_name) - log.engineer = data.get('engineer', log.engineer) - log.location = data.get('location', log.location) - log.content = data.get('content', log.content) - db.session.commit() - return jsonify({'code': 200, 'message': 'Log updated'}) - except Exception as e: - db.session.rollback() - return jsonify({'code': 500, 'message': str(e)}) + # 2. 查库时需要 int + uid = int(user_id) + u = User.query.get(uid) + return u and u.role == 'admin' + except: + return False -@api_bp.route('/logs/delete', methods=['POST']) -def delete_log(): - data = request.get_json() - log = MaintenanceLog.query.get(data.get('id')) - if log: - db.session.delete(log) - db.session.commit() - return jsonify({'code': 200, 'message': 'Deleted'}) - return jsonify({'code': 404, 'message': 'Not found'}), 404 - - -# ======================= -# 3. 辅助与控制接口 (核心修复逻辑) -# ======================= - def calculate_offset(latest_time_str): if not latest_time_str or latest_time_str == "N/A": return "从未同步" try: @@ -180,28 +51,239 @@ def calculate_offset(latest_time_str): return "时间解析失败" -@api_bp.route('/run_monitor', methods=['POST']) -def run_monitor(): - try: - if not execute_monitor_task: - return jsonify({'code': 500, 'message': 'Core module missing'}) +# ======================= +# 0. 认证接口 +# ======================= +@api_bp.route('/login', methods=['POST']) +def login(): + data = request.get_json() + username = data.get('username') + password = data.get('password') + # 1. 查库登录 + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + # 🟢 修复核心:必须用 str() 将 ID 转为字符串 + # 否则 flask-jwt-extended 会报错 "Subject must be a string" + 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 + }) + + # 2. 硬编码后门 (修正:生成真实 Token) + if username == 'admin' and password == 'licahk': + # 🟢 修复核心:'0' 必须是字符串 + token = create_access_token( + identity='0', + additional_claims={'role': 'admin'} + ) + return jsonify({ + 'code': 200, 'message': 'Root登录', + 'token': token, 'role': 'admin', 'user_id': 0 + }) + + return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401 + + +# ======================= +# 1. 设备接口 +# ======================= +@api_bp.route('/devices_overview', methods=['GET']) +@jwt_required() +def devices_overview(): + try: + # 获取到的 user_id 现在是字符串类型 (例如 "1" 或 "0") + user_id = get_jwt_identity() + + target_devices = [] + if is_admin(user_id): + target_devices = Device.query.all() + else: + # 普通用户权限查询 (users_db) + # 必须转回 int 才能去数据库查 ID + 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 in devices_overview: {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: + # 尝试转JSON对象返回 + 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) +# ======================= +@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 + + clients = User.query.filter_by(role='client').all() + result = [] + for c in clients: + perms = UserDevicePermission.query.filter_by(user_id=c.id).all() + result.append({ + "id": c.id, "username": c.username, "created_at": c.created_at, + "allowed_device_ids": [p.device_id for p in perms] + }) + return jsonify(result) + + +@api_bp.route('/admin/create_client', methods=['POST']) +@jwt_required() +def admin_create_client(): + if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 + data = request.get_json() + if User.query.filter_by(username=data['username']).first(): + return jsonify({'code': 400, 'msg': '用户已存在'}), 400 + + u = User(username=data['username'], role='client') + u.set_password(data['password']) + db.session.add(u) + 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') + d_ids = data.get('device_ids', []) + + UserDevicePermission.query.filter_by(user_id=uid).delete() + for did in d_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']) +def get_logs(): + keyword = request.args.get('keyword', '') + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + query = MaintenanceLog.query + if keyword: + kw = f"%{keyword}%" + query = query.filter(or_( + MaintenanceLog.device_name.like(kw), MaintenanceLog.engineer.like(kw), + MaintenanceLog.location.like(kw), MaintenanceLog.content.like(kw) + )) + if start_date and end_date: + try: + 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(): + data = request.get_json() + db.session.add(MaintenanceLog( + device_name=data.get('device_name'), + engineer=data.get('engineer'), + location=data.get('location'), + content=data.get('content') + )) + db.session.commit() + return jsonify({'code': 200}) + + +@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}) + + +@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, 'message': '任务跳过'}) + if not task_result: return jsonify({'code': 200, 'msg': '跳过'}) scraped_list = task_result.get('device_list', []) - current_check_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - + 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', {}) - source = item.get('source', '') target_time = item.get('target_time') + source = item.get('source', '') - # 处理 106 路径时间 if '106' in str(source): try: path_str = d_raw.get('path', '') @@ -211,69 +293,66 @@ def run_monitor(): except: pass - json_str = json.dumps(d_raw, ensure_ascii=False) if isinstance(d_raw, (dict, list)) else str(d_raw) + 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() # 获取 ID 供 History 使用 + db.session.flush() - # 仅更新动态抓取的字段,保留手动填写的 install_site, is_maintaining, is_hidden device.status = item.get('status') device.current_value = item.get('value') device.latest_time = target_time - device.check_time = current_check_time + device.check_time = now_str device.json_data = json_str device.offset = calculate_offset(target_time) - new_history = DeviceHistory( - device_id=device.id, - status=item.get('status'), - result_data=item.get('value'), - data_time=target_time, + db.session.add(DeviceHistory( + device_id=device.id, status=device.status, + result_data=device.current_value, data_time=target_time, json_data=json_str - ) - db.session.add(new_history) + )) count += 1 db.session.commit() - return jsonify({'code': 200, 'message': f'成功更新 {count} 台设备,资料已保留'}) + 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(): - data = request.get_json() - device = Device.query.filter_by(name=data.get('name')).first() - if device: - device.install_site = data.get('site') + 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.install_site = request.get_json().get('site') db.session.commit() return jsonify({'code': 200}) - return jsonify({'code': 404}), 404 + return jsonify({'code': 404}) @api_bp.route('/toggle_maintenance', methods=['POST']) +@jwt_required() def toggle_maintenance(): - data = request.get_json() - device = Device.query.filter_by(name=data.get('name')).first() - if device: - device.is_maintaining = data.get('is_maintaining') + 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_maintaining = request.get_json().get('is_maintaining') db.session.commit() return jsonify({'code': 200}) - return jsonify({'code': 404}), 404 + return jsonify({'code': 404}) @api_bp.route('/toggle_hidden', methods=['POST']) +@jwt_required() def toggle_hidden(): - data = request.get_json() - device = Device.query.filter_by(name=data.get('name')).first() - if device: - device.is_hidden = data.get('is_hidden') + 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}), 404 \ No newline at end of file + return jsonify({'code': 404}) \ No newline at end of file diff --git a/zhandianxinxi/光谱数据监控/src/App.vue b/zhandianxinxi/光谱数据监控/src/App.vue index 1752628..40d13ef 100644 --- a/zhandianxinxi/光谱数据监控/src/App.vue +++ b/zhandianxinxi/光谱数据监控/src/App.vue @@ -5,7 +5,7 @@
- 2.1版本 © 2026 Device Monitor + 2.2版本(权限管理版) © 2026 Device Monitor
diff --git a/zhandianxinxi/光谱数据监控/src/main.js b/zhandianxinxi/光谱数据监控/src/main.js index 36acf25..faedf74 100644 --- a/zhandianxinxi/光谱数据监控/src/main.js +++ b/zhandianxinxi/光谱数据监控/src/main.js @@ -1,30 +1,18 @@ +// src/main.js import { createApp } from 'vue' -import App from './App.vue' // 引入根组件 -import router from './router' // 引入路由配置 - -// 引入 Element Plus +import App from './App.vue' +import router from './router' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import * as ElementPlusIconsVue from '@element-plus/icons-vue' - -// 引入 JSON 查看器 (用于 DataMonitor 中查看原始数据) import JsonViewer from 'vue-json-viewer' const app = createApp(App) -// 1. 挂载路由 app.use(router) - -// 2. 挂载 Element Plus app.use(ElementPlus) - -// 3. 注册所有图标 (方便在各个组件直接使用 等) for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } - -// 4. 挂载 JSON Viewer app.use(JsonViewer) - -// 5. 挂载到 DOM app.mount('#app') diff --git a/zhandianxinxi/光谱数据监控/src/router/index.js b/zhandianxinxi/光谱数据监控/src/router/index.js index 044addc..2027c94 100644 --- a/zhandianxinxi/光谱数据监控/src/router/index.js +++ b/zhandianxinxi/光谱数据监控/src/router/index.js @@ -1,10 +1,11 @@ import { createRouter, createWebHistory } from 'vue-router' import { ElMessage } from 'element-plus' -// 1. 引入登录页面(建议新建 views/Login.vue) +// 1. 引入页面组件 import Login from '../views/Login.vue' -// 2. 首页组件 import Dashboard from '../views/Dashboard.vue' +// 新增:引入用户管理页面 (确保你在 views 目录下创建了 UserManagement.vue) +import UserManagement from '../views/UserManagement.vue' const routes = [ { @@ -19,6 +20,13 @@ const routes = [ component: Dashboard, meta: { title: '设备监控总览', requiresAuth: true } }, + // 新增:用户管理路由 + { + path: '/user-management', + name: 'UserManagement', + component: UserManagement, + meta: { title: '客户权限管理', requiresAuth: true } + }, { path: '/data-monitor', name: 'CrawledData', @@ -32,7 +40,7 @@ const routes = [ component: () => import('../views/MaintenanceLogs.vue'), meta: { title: '维修日志中心', requiresAuth: true } }, - // 捕获所有未定义的路径,跳转回登录页或首页 + // 捕获所有未定义的路径,跳转回登录页 { path: '/:pathMatch(.*)*', redirect: '/' diff --git a/zhandianxinxi/光谱数据监控/src/utils/request.js b/zhandianxinxi/光谱数据监控/src/utils/request.js new file mode 100644 index 0000000..2049bb2 --- /dev/null +++ b/zhandianxinxi/光谱数据监控/src/utils/request.js @@ -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 diff --git a/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue b/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue index 0bdf1ec..ee2b52c 100644 --- a/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue +++ b/zhandianxinxi/光谱数据监控/src/views/Dashboard.vue @@ -13,6 +13,16 @@
+ + 用户管理 + + 日志 @@ -168,9 +178,10 @@ @@ -290,7 +289,6 @@ defineExpose({ open }) gap: 4px; } -/* 调整输入框禁用时的样式,保持可读性 */ :deep(.el-input.is-disabled .el-input__wrapper) { background-color: #f5f7fa; box-shadow: 0 0 0 1px #e4e7ed inset; diff --git a/zhandianxinxi/光谱数据监控/src/views/UserManagement.vue b/zhandianxinxi/光谱数据监控/src/views/UserManagement.vue new file mode 100644 index 0000000..59f75da --- /dev/null +++ b/zhandianxinxi/光谱数据监控/src/views/UserManagement.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/zhandianxinxi/光谱数据监控/src/views/login.vue b/zhandianxinxi/光谱数据监控/src/views/login.vue index 7424c83..84e5765 100644 --- a/zhandianxinxi/光谱数据监控/src/views/login.vue +++ b/zhandianxinxi/光谱数据监控/src/views/login.vue @@ -19,7 +19,8 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import axios from 'axios' +// 🔴 修改点:引入封装好的 request,而不是 axios +import request from '../utils/request' const router = useRouter() const loading = ref(false) @@ -32,16 +33,35 @@ const handleLogin = async () => { loading.value = true try { - const res = await axios.post('/api/login', loginForm.value) - if (res.data.code === 200) { - // 存储登录状态 + // 🔴 使用 request 发送请求 + const res = await request.post('/api/login', loginForm.value) + const data = res.data + + // 兼容逻辑 + const code = data.code !== undefined ? data.code : 200 + + if (code === 200) { + // 🛡️ 安全检查:防止存入 undefined + if (!data.token) { + ElMessage.error('登录异常:服务器未返回 Token') + return + } + + console.log('登录成功,Token:', data.token) // 调试用 + localStorage.setItem('isLoggedIn', 'true') - localStorage.setItem('token', res.data.token) + localStorage.setItem('token', data.token) + localStorage.setItem('role', data.role || 'client') + localStorage.setItem('user_id', data.user_id || '') + ElMessage.success('欢迎回来') - router.push('/dashboard') // 登录成功跳转 + router.push('/dashboard') + } else { + ElMessage.error(data.message || '登录失败') } } catch (error) { - ElMessage.error(error.response?.data?.message || '登录失败') + console.error(error) + // request.js 里已经拦截了一部分错误,这里只需处理 loading } finally { loading.value = false }