2.3权限管理,可添加运行

This commit is contained in:
YueL1331
2026-01-09 15:18:24 +08:00
parent 9c73e25937
commit c416c8ad07
14 changed files with 835 additions and 424 deletions

View File

@ -1,92 +1,55 @@
import os import os
import sys import sys
import json import json
import logging
import mimetypes import mimetypes
from datetime import datetime from datetime import datetime
from flask import Flask, send_from_directory, jsonify from flask import Flask, send_from_directory, jsonify
from flask_cors import CORS 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: try:
# 数据库实例 (在根目录 extensions.py 中) from routes.api import api_bp, calculate_offset
from extensions import db except ImportError:
api_bp = None
calculate_offset = None
# 数据模型 (在根目录 models.py 中) # 引入爬虫服务
from models import Device, DeviceHistory, MaintenanceLog try:
# 核心业务逻辑 (在 services/core.py 中)
from services.core import execute_monitor_task from services.core import execute_monitor_task
except ImportError:
execute_monitor_task = None
# 路由蓝图 (在 routes/api.py 中) # 注册 MIME 类型 (防止前端 JS/CSS 加载报 404 或类型错误)
try:
from routes.api import api_bp as device_bp
except ImportError:
from routes.api import device_bp
# 工具函数 (在 routes/api.py 中)
from routes.api import calculate_offset
except ImportError as e:
print(f"❌ 严重错误: 模块导入失败。请检查文件名和变量名。详细信息: {e}")
print(f"系统路径: {sys.path}")
sys.exit(1)
# ==============================================================================
# 2. 路径计算模块 (兼容 PyInstaller 打包)
# ==============================================================================
def get_base_path():
"""获取运行时基准路径,兼容开发环境和打包环境"""
if getattr(sys, 'frozen', False):
if hasattr(sys, '_MEIPASS'):
return sys._MEIPASS # --onefile 模式
else:
return os.path.dirname(os.path.abspath(sys.executable)) # --onedir 模式
else:
return os.path.abspath(os.path.dirname(__file__))
BASE_DIR = get_base_path()
STATIC_FOLDER = os.path.join(BASE_DIR, 'web_dist')
INSTANCE_FOLDER = os.path.join(BASE_DIR, 'instance')
DB_PATH = os.path.join(INSTANCE_FOLDER, 'devices.db')
# 修复 Windows 下注册表 MIME 类型缺失导致网页白屏的问题
mimetypes.add_type('application/javascript', '.js') mimetypes.add_type('application/javascript', '.js')
mimetypes.add_type('text/css', '.css') 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): def auto_monitor_job(app):
"""定时任务具体执行逻辑"""
with app.app_context(): 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: if not execute_monitor_task:
print("❌ 错误: 无法加载爬虫核心模块 (execute_monitor_task is Missing)") print("❌ 错误: 爬虫模块未加载")
return return
try: try:
# 执行爬取
task_result = execute_monitor_task() task_result = execute_monitor_task()
if not task_result: if not task_result:
print("⚠️ [定时任务] 爬虫未获取到数据") print("⚠️ 未抓取到数据")
return return
scraped_list = task_result.get('device_list', []) scraped_list = task_result.get('device_list', [])
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
count = 0 count = 0
for item in scraped_list: for item in scraped_list:
d_name = item.get('name') d_name = item.get('name')
if not d_name: continue if not d_name: continue
@ -98,15 +61,17 @@ def auto_monitor_job(app):
db.session.add(device) db.session.add(device)
db.session.flush() db.session.flush()
# 更新字段 # 更新状态
device.status = item.get('status') device.status = item.get('status')
device.current_value = item.get('value') device.current_value = item.get('value')
device.latest_time = item.get('target_time') device.latest_time = item.get('target_time')
device.check_time = current_time device.check_time = current_time
device.json_data = json.dumps(item.get('raw_json', {}), ensure_ascii=False) 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( db.session.add(DeviceHistory(
device_id=device.id, device_id=device.id,
status=device.status, status=device.status,
@ -117,86 +82,143 @@ def auto_monitor_job(app):
count += 1 count += 1
db.session.commit() db.session.commit()
print(f"✅ [定时任务] 成功更新 {count} 台设备状态") print(f"✅ [定时任务] 更新 {count} 台设备")
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print(f"❌ [定时任务] 异常: {str(e)}") print(f"❌ [定时任务] 异常: {str(e)}")
# ============================================================================== # --- App 工厂 ---
# 4. Flask 应用工厂
# ==============================================================================
def create_app(): def create_app():
# 🔴 关键修复:移除了 static_url_path='' app = Flask(__name__, static_folder=Config.STATIC_FOLDER)
# 这样 Flask 就不会强制拦截所有根路径请求,让下面的 serve_static 有机会处理 /dashboard
app = Flask(__name__, static_folder=STATIC_FOLDER)
CORS(app) # 1. 加载配置 (包含数据库、JWT、爬虫配置)
app.config.from_object(Config)
# 确保 instance 目录存在 # 2. 初始化扩展
if not os.path.exists(INSTANCE_FOLDER):
os.makedirs(INSTANCE_FOLDER, exist_ok=True)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SCHEDULER_API_ENABLED'] = True
# 初始化数据库
db.init_app(app) db.init_app(app)
jwt.init_app(app)
# 初始化定时任务
scheduler = APScheduler()
scheduler.init_app(app) scheduler.init_app(app)
scheduler.start()
# 添加定时任务 (每天 10:00) # 3. 配置 CORS (允许 Authorization 头,解决 401/422 的关键)
scheduler.add_job( # 允许所有来源,允许凭证,允许关键 Header
id='daily_monitor_task', CORS(app,
func=auto_monitor_job, resources={r"/*": {"origins": "*"}},
args=[app], supports_credentials=True,
trigger='cron', allow_headers=["Content-Type", "Authorization", "X-Requested-With"])
hour=10,
minute=0
)
# 注册蓝图 # 4. 注册蓝图
app.register_blueprint(device_bp) 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 <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=10,
minute=0
)
if not scheduler.running:
scheduler.start()
# 7. 静态文件路由
@app.route('/') @app.route('/')
def serve_index(): def serve_index():
if not os.path.exists(os.path.join(app.static_folder, 'index.html')): 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') return send_from_directory(app.static_folder, 'index.html')
@app.route('/<path:path>') @app.route('/<path:path>')
def serve_static(path): def serve_static(path):
# 1. 优先尝试直接返回实际存在的文件 (js, css, img等)
file_path = os.path.join(app.static_folder, path) file_path = os.path.join(app.static_folder, path)
if os.path.exists(file_path): if os.path.exists(file_path):
return send_from_directory(app.static_folder, path) return send_from_directory(app.static_folder, path)
# 2. 如果是 API 请求但没找到对应接口,返回 404 JSON (不返回 HTML) # 如果不是静态文件请求,也不是 api 请求,就返回 index.html (前端路由)
if path.startswith('api') or path.startswith('static'): if path.startswith('api'):
return jsonify({'code': 404, 'message': 'Not Found'}), 404 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') return send_from_directory(app.static_folder, 'index.html')
# 8. 初始化数据库和默认管理员
with app.app_context(): with app.app_context():
# db.create_all() 会根据 binds 配置自动创建 users.db 和 devices.db
db.create_all() 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 return app
if __name__ == '__main__': if __name__ == '__main__':
# 确保在主程序块中运行
app = create_app() app = create_app()
# 生产环境/打包环境通常设为 False
# 判断是否为打包后的环境
debug_mode = not getattr(sys, 'frozen', 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) app.run(host='0.0.0.0', port=5000, debug=debug_mode, use_reloader=False)

View File

@ -1,34 +1,51 @@
import os import os
import sys import sys
from datetime import timedelta
def get_base_path(): def get_base_path():
"""获取运行时路径 (兼容打包后的 exe 和开发环境)""" """获取运行时路径 (兼容打包后的 exe 和开发环境)"""
if getattr(sys, 'frozen', False): 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__)) 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: class Config:
BASE_DIR = get_base_path() 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 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_API_ENABLED = True
SCHEDULER_TIMEZONE = "Asia/Shanghai" # 👈 必须加这个,否则 APScheduler 可能报错 SCHEDULER_TIMEZONE = "Asia/Shanghai"
# --- 爬虫配置 (Service层会读取这里) --- # --- 爬虫配置 (保留你原有的配置) ---
CRAWLER_CONFIG = { CRAWLER_CONFIG = {
"106": { "106": {
"base_url": "http://106.75.72.40:7500/api/proxy/tcp", "base_url": "http://106.75.72.40:7500/api/proxy/tcp",

View File

@ -1,9 +1,10 @@
#extensions.py
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS from flask_cors import CORS
from flask_apscheduler import APScheduler from flask_apscheduler import APScheduler
from flask_jwt_extended import JWTManager
# 这里只创建对象,不绑定 app # 这里只创建对象,不绑定 app
db = SQLAlchemy() db = SQLAlchemy()
cors = CORS() cors = CORS()
scheduler = APScheduler() scheduler = APScheduler()
jwt = JWTManager()

View File

@ -1,9 +1,14 @@
from datetime import datetime from datetime import datetime
import json from werkzeug.security import generate_password_hash, check_password_hash
from extensions import db from extensions import db
# =======================
# 数据库 1: 业务数据 (devices.db)
# =======================
class Device(db.Model): class Device(db.Model):
__tablename__ = 'devices' __tablename__ = 'devices'
# 默认数据库,无需 bind_key
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, index=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)) reason = db.Column(db.String(255))
offset = db.Column(db.String(50)) offset = db.Column(db.String(50))
# 手动录入字段受保护run_monitor 不主动覆盖) # 手动录入字段
install_site = db.Column(db.String(100), default="") install_site = db.Column(db.String(100), default="")
is_maintaining = db.Column(db.Boolean, default=False) is_maintaining = db.Column(db.Boolean, default=False)
is_hidden = db.Column(db.Boolean, default=False) is_hidden = db.Column(db.Boolean, default=False)
def to_dict(self): def to_dict(self):
# 统一状态映射逻辑
api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online' api_status = 'offline' if self.status in ['离线', '异常', '已离线'] else 'online'
return { return {
'id': self.id, 'id': self.id,
@ -52,7 +56,6 @@ class DeviceHistory(db.Model):
result_data = db.Column(db.String(200), default="") result_data = db.Column(db.String(200), default="")
json_data = db.Column(db.Text) json_data = db.Column(db.Text)
file_path = db.Column(db.String(255)) file_path = db.Column(db.String(255))
recorded_at = db.Column(db.DateTime, default=datetime.now) recorded_at = db.Column(db.DateTime, default=datetime.now)
class MaintenanceLog(db.Model): class MaintenanceLog(db.Model):
@ -72,4 +75,37 @@ class MaintenanceLog(db.Model):
'location': self.location or '', 'location': self.location or '',
'content': self.content, 'content': self.content,
'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S') 'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
} }
# =======================
# 数据库 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

View File

@ -1,12 +1,13 @@
import os
import shutil
import json import json
import re import re
from datetime import datetime from datetime import datetime
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from sqlalchemy import desc, or_ 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 extensions import db
from models import Device, DeviceHistory, MaintenanceLog from models import Device, DeviceHistory, MaintenanceLog, User, UserDevicePermission
# 尝试导入爬虫模块 # 尝试导入爬虫模块
try: 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']) if not user_id: return False
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
if username == 'admin' and password == 'licahk':
return jsonify({
'code': 200,
'message': '登录成功',
'token': 'super-admin-token-2026',
'user': {'username': 'admin', 'role': 'administrator'}
})
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
# =======================
# 1. 设备概览与详情接口
# =======================
@api_bp.route('/devices_overview', methods=['GET'])
def devices_overview():
try:
devices = Device.query.all()
data_list = [d.to_dict() for d in devices]
return jsonify({'code': 200, 'data': data_list})
except Exception as e:
return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/device_data_by_date', methods=['GET'])
def device_data_by_date():
name = request.args.get('name')
date_str = request.args.get('date')
if not name or not date_str:
return jsonify({'code': 400, 'message': 'Missing name or date'}), 400
device = Device.query.filter_by(name=name).first()
if not device:
return jsonify({'code': 404, 'message': 'Device not found'}), 404
content = None
history_record = DeviceHistory.query.filter(
DeviceHistory.device_id == device.id,
DeviceHistory.data_time.like(f"{date_str}%")
).order_by(desc(DeviceHistory.id)).first()
if history_record:
content = history_record.json_data
elif device.latest_time and device.latest_time.startswith(date_str):
content = device.json_data
if content:
return jsonify({
'code': 200,
'name': device.name,
'source': device.source,
'content': content
})
return jsonify({'code': 404, 'message': 'No data for this date'}), 404
# =======================
# 2. 维修日志接口
# =======================
@api_bp.route('/logs/list', methods=['GET'])
def get_logs():
keyword = request.args.get('keyword', '')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
query = MaintenanceLog.query
if keyword:
kw = f"%{keyword}%"
query = query.filter(or_(
MaintenanceLog.device_name.like(kw),
MaintenanceLog.engineer.like(kw),
MaintenanceLog.location.like(kw),
MaintenanceLog.content.like(kw)
))
if start_date and end_date:
try:
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
end_dt = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
query = query.filter(MaintenanceLog.timestamp.between(start_dt, end_dt))
except ValueError:
pass
logs = query.order_by(MaintenanceLog.timestamp.desc()).all()
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
@api_bp.route('/logs/add', methods=['POST'])
def add_log():
data = request.get_json()
try:
new_log = MaintenanceLog(
device_name=data.get('device_name', '未知设备'),
engineer=data.get('engineer', ''),
location=data.get('location', ''),
content=data.get('content', '')
)
db.session.add(new_log)
db.session.commit()
return jsonify({'code': 200, 'message': 'Log saved'})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/logs/update', methods=['POST'])
def update_log():
data = request.get_json()
log_id = data.get('id')
log = MaintenanceLog.query.get(log_id)
if not log: return jsonify({'code': 404, 'message': 'Not found'}), 404
try: try:
log.device_name = data.get('device_name', log.device_name) # 2. 查库时需要 int
log.engineer = data.get('engineer', log.engineer) uid = int(user_id)
log.location = data.get('location', log.location) u = User.query.get(uid)
log.content = data.get('content', log.content) return u and u.role == 'admin'
db.session.commit() except:
return jsonify({'code': 200, 'message': 'Log updated'}) return False
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/logs/delete', methods=['POST'])
def delete_log():
data = request.get_json()
log = MaintenanceLog.query.get(data.get('id'))
if log:
db.session.delete(log)
db.session.commit()
return jsonify({'code': 200, 'message': 'Deleted'})
return jsonify({'code': 404, 'message': 'Not found'}), 404
# =======================
# 3. 辅助与控制接口 (核心修复逻辑)
# =======================
def calculate_offset(latest_time_str): def calculate_offset(latest_time_str):
if not latest_time_str or latest_time_str == "N/A": return "从未同步" if not latest_time_str or latest_time_str == "N/A": return "从未同步"
try: try:
@ -180,28 +51,239 @@ def calculate_offset(latest_time_str):
return "时间解析失败" return "时间解析失败"
@api_bp.route('/run_monitor', methods=['POST']) # =======================
def run_monitor(): # 0. 认证接口
try: # =======================
if not execute_monitor_task: @api_bp.route('/login', methods=['POST'])
return jsonify({'code': 500, 'message': 'Core module missing'}) 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() 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', []) 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 count = 0
for item in scraped_list: for item in scraped_list:
d_name = item.get('name') d_name = item.get('name')
if not d_name: continue if not d_name: continue
# --- 原始保留的逻辑:处理特殊路径 ---
d_raw = item.get('raw_json', {}) d_raw = item.get('raw_json', {})
source = item.get('source', '')
target_time = item.get('target_time') target_time = item.get('target_time')
source = item.get('source', '')
# 处理 106 路径时间
if '106' in str(source): if '106' in str(source):
try: try:
path_str = d_raw.get('path', '') path_str = d_raw.get('path', '')
@ -211,69 +293,66 @@ def run_monitor():
except: except:
pass 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() device = Device.query.filter_by(name=d_name).first()
if not device: if not device:
# 只有新设备才初始化静态字段
device = Device(name=d_name, source=source, install_site="") device = Device(name=d_name, source=source, install_site="")
db.session.add(device) db.session.add(device)
db.session.flush() # 获取 ID 供 History 使用 db.session.flush()
# 仅更新动态抓取的字段,保留手动填写的 install_site, is_maintaining, is_hidden
device.status = item.get('status') device.status = item.get('status')
device.current_value = item.get('value') device.current_value = item.get('value')
device.latest_time = target_time device.latest_time = target_time
device.check_time = current_check_time device.check_time = now_str
device.json_data = json_str device.json_data = json_str
device.offset = calculate_offset(target_time) device.offset = calculate_offset(target_time)
new_history = DeviceHistory( db.session.add(DeviceHistory(
device_id=device.id, device_id=device.id, status=device.status,
status=item.get('status'), result_data=device.current_value, data_time=target_time,
result_data=item.get('value'),
data_time=target_time,
json_data=json_str json_data=json_str
) ))
db.session.add(new_history)
count += 1 count += 1
db.session.commit() db.session.commit()
return jsonify({'code': 200, 'message': f'成功更新 {count} 台设备,资料已保留'}) return jsonify({'code': 200, 'message': f'成功更新 {count} 台设备'})
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'code': 500, 'message': str(e)}) return jsonify({'code': 500, 'message': str(e)})
@api_bp.route('/update_site', methods=['POST']) @api_bp.route('/update_site', methods=['POST'])
@jwt_required()
def update_site(): def update_site():
data = request.get_json() if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
device = Device.query.filter_by(name=data.get('name')).first() d = Device.query.filter_by(name=request.get_json().get('name')).first()
if device: if d:
device.install_site = data.get('site') d.install_site = request.get_json().get('site')
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}), 404 return jsonify({'code': 404})
@api_bp.route('/toggle_maintenance', methods=['POST']) @api_bp.route('/toggle_maintenance', methods=['POST'])
@jwt_required()
def toggle_maintenance(): def toggle_maintenance():
data = request.get_json() if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
device = Device.query.filter_by(name=data.get('name')).first() d = Device.query.filter_by(name=request.get_json().get('name')).first()
if device: if d:
device.is_maintaining = data.get('is_maintaining') d.is_maintaining = request.get_json().get('is_maintaining')
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}), 404 return jsonify({'code': 404})
@api_bp.route('/toggle_hidden', methods=['POST']) @api_bp.route('/toggle_hidden', methods=['POST'])
@jwt_required()
def toggle_hidden(): def toggle_hidden():
data = request.get_json() if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
device = Device.query.filter_by(name=data.get('name')).first() d = Device.query.filter_by(name=request.get_json().get('name')).first()
if device: if d:
device.is_hidden = data.get('is_hidden') d.is_hidden = request.get_json().get('is_hidden')
db.session.commit() db.session.commit()
return jsonify({'code': 200}) return jsonify({'code': 200})
return jsonify({'code': 404}), 404 return jsonify({'code': 404})

View File

@ -5,7 +5,7 @@
</main> </main>
<footer class="version-footer"> <footer class="version-footer">
2.1版本 © 2026 Device Monitor 2.2版本(权限管理版) © 2026 Device Monitor
</footer> </footer>
</div> </div>
</template> </template>

View File

@ -1,30 +1,18 @@
// src/main.js
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' // 引入根组件 import App from './App.vue'
import router from './router' // 引入路由配置 import router from './router'
// 引入 Element Plus
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
// 引入 JSON 查看器 (用于 DataMonitor 中查看原始数据)
import JsonViewer from 'vue-json-viewer' import JsonViewer from 'vue-json-viewer'
const app = createApp(App) const app = createApp(App)
// 1. 挂载路由
app.use(router) app.use(router)
// 2. 挂载 Element Plus
app.use(ElementPlus) app.use(ElementPlus)
// 3. 注册所有图标 (方便在各个组件直接使用 <Edit /> 等)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) { for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component) app.component(key, component)
} }
// 4. 挂载 JSON Viewer
app.use(JsonViewer) app.use(JsonViewer)
// 5. 挂载到 DOM
app.mount('#app') app.mount('#app')

View File

@ -1,10 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
// 1. 引入登录页面(建议新建 views/Login.vue // 1. 引入页面组件
import Login from '../views/Login.vue' import Login from '../views/Login.vue'
// 2. 首页组件
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
// 新增:引入用户管理页面 (确保你在 views 目录下创建了 UserManagement.vue)
import UserManagement from '../views/UserManagement.vue'
const routes = [ const routes = [
{ {
@ -19,6 +20,13 @@ const routes = [
component: Dashboard, component: Dashboard,
meta: { title: '设备监控总览', requiresAuth: true } meta: { title: '设备监控总览', requiresAuth: true }
}, },
// 新增:用户管理路由
{
path: '/user-management',
name: 'UserManagement',
component: UserManagement,
meta: { title: '客户权限管理', requiresAuth: true }
},
{ {
path: '/data-monitor', path: '/data-monitor',
name: 'CrawledData', name: 'CrawledData',
@ -32,7 +40,7 @@ const routes = [
component: () => import('../views/MaintenanceLogs.vue'), component: () => import('../views/MaintenanceLogs.vue'),
meta: { title: '维修日志中心', requiresAuth: true } meta: { title: '维修日志中心', requiresAuth: true }
}, },
// 捕获所有未定义的路径,跳转回登录页或首页 // 捕获所有未定义的路径,跳转回登录页
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
redirect: '/' redirect: '/'

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

@ -13,6 +13,16 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-button
v-if="userRole === 'admin'"
type="primary"
plain
icon="Avatar"
@click="goToUserManagement"
>
用户管理
</el-button>
<el-button type="info" plain icon="Document" @click="openLogCenter(null)"> <el-button type="info" plain icon="Document" @click="openLogCenter(null)">
日志 日志
</el-button> </el-button>
@ -168,9 +178,10 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue' import { ref, reactive, computed, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios' // 🔴 修改 1: 引入 request 替代 axios
import request from '../utils/request'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton } from '@element-plus/icons-vue' import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Avatar } from '@element-plus/icons-vue'
// 引入子组件 // 引入子组件
import DataMonitor from './DataMonitor.vue' import DataMonitor from './DataMonitor.vue'
@ -184,16 +195,19 @@ const lastCheckTime = ref('')
const windowHeight = ref(window.innerHeight) const windowHeight = ref(window.innerHeight)
const windowWidth = ref(window.innerWidth) const windowWidth = ref(window.innerWidth)
// 身份权限控制
const userRole = ref('') // 存储用户角色
// 计算表格高度:手机端预留更多空间给折行的头部 // 计算表格高度:手机端预留更多空间给折行的头部
const tableHeight = computed(() => { const tableHeight = computed(() => {
const isMobile = windowWidth.value < 768 const isMobile = windowWidth.value < 768
// 手机端头部元素堆叠,需要减去更多的高度
const offset = isMobile ? 380 : 250 const offset = isMobile ? 380 : 250
return windowHeight.value - offset return windowHeight.value - offset
}) })
const filters = reactive({ status: 'all', keyword: '' }) const filters = reactive({ status: 'all', keyword: '' })
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
// 🔴 修改 2: 删除了 API_BASE因为 request.js 已经配置了 baseURL
const dataMonitorRef = ref(null) const dataMonitorRef = ref(null)
const maintenanceLogsRef = ref(null) const maintenanceLogsRef = ref(null)
@ -206,10 +220,17 @@ const summary = computed(() => {
return { errorCount: errors, warningCount: warnings, hiddenCount: hidden } return { errorCount: errors, warningCount: warnings, hiddenCount: hidden }
}) })
// 跳转到用户管理页
const goToUserManagement = () => {
router.push('/user-management')
}
const handleLogout = () => { const handleLogout = () => {
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => { ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
localStorage.removeItem('isLoggedIn') localStorage.removeItem('isLoggedIn')
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('role')
localStorage.removeItem('user_id')
router.push('/') router.push('/')
ElMessage.success('已安全退出') ElMessage.success('已安全退出')
}).catch(() => {}) }).catch(() => {})
@ -218,7 +239,10 @@ const handleLogout = () => {
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
const res = await axios.get(`${API_BASE}/api/devices_overview`) // 🔴 修改 3: 直接使用 request.get无需手动获取 token 和配置 headers
// request.js 的拦截器会自动完成这一切
const res = await request.get('/api/devices_overview')
const backendList = res.data.data || res.data const backendList = res.data.data || res.data
const now = new Date() const now = new Date()
@ -264,7 +288,8 @@ const fetchData = async () => {
rawData.value = processedData rawData.value = processedData
lastCheckTime.value = new Date().toLocaleString() lastCheckTime.value = new Date().toLocaleString()
} catch (e) { } catch (e) {
ElMessage.error('获取数据失败') // request.js 会处理 401/422这里主要处理网络错误
console.error(e)
} finally { } finally {
loading.value = false loading.value = false
} }
@ -272,14 +297,19 @@ const fetchData = async () => {
const handleDeviceClick = (row) => { if (!row.is_hidden && dataMonitorRef.value) dataMonitorRef.value.open(row) } 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 openLogCenter = (row) => { if (maintenanceLogsRef.value) maintenanceLogsRef.value.open(row ? { deviceName: row.name } : null) }
const runManualMonitor = async () => { const runManualMonitor = async () => {
runningTask.value = true runningTask.value = true
try { try {
const res = await axios.post(`${API_BASE}/api/run_monitor`) // 🔴 修改 4: 使用 request
const res = await request.post('/api/run_monitor')
ElMessage.success(res.data.message || '任务启动') ElMessage.success(res.data.message || '任务启动')
setTimeout(() => fetchData(), 3000) setTimeout(() => fetchData(), 3000)
} catch (e) { ElMessage.warning('请求频繁') } } catch (e) {
finally { setTimeout(() => { runningTask.value = false }, 1000) } ElMessage.warning('请求频繁或失败')
} finally {
setTimeout(() => { runningTask.value = false }, 1000)
}
} }
const filteredData = computed(() => { const filteredData = computed(() => {
@ -294,7 +324,6 @@ const filteredData = computed(() => {
const handleEditSite = (row) => { const handleEditSite = (row) => {
row.tempSite = row.install_site; row.isEditingSite = true row.tempSite = row.install_site; row.isEditingSite = true
nextTick(() => { nextTick(() => {
// 兼容性查找 input
const inputs = document.querySelectorAll('.site-input-inner input') const inputs = document.querySelectorAll('.site-input-inner input')
if (inputs.length > 0) inputs[inputs.length - 1].focus() if (inputs.length > 0) inputs[inputs.length - 1].focus()
}) })
@ -305,7 +334,8 @@ const saveSite = async (row) => {
const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false const oldVal = row.install_site; row.install_site = row.tempSite; row.isEditingSite = false
if (oldVal === row.tempSite) return if (oldVal === row.tempSite) return
try { try {
await axios.post(`${API_BASE}/api/update_site`, { name: row.name, site: row.tempSite }) // 🔴 修改 5: 使用 request
await request.post('/api/update_site', { name: row.name, site: row.tempSite })
ElMessage.success('已更新') ElMessage.success('已更新')
} catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') } } catch (e) { row.install_site = oldVal; ElMessage.error('更新失败') }
} }
@ -313,7 +343,8 @@ const saveSite = async (row) => {
const handleMaintenanceBeforeChange = (row) => { const handleMaintenanceBeforeChange = (row) => {
return new Promise((resolve) => { return new Promise((resolve) => {
const newVal = !row.is_maintaining const newVal = !row.is_maintaining
axios.post(`${API_BASE}/api/toggle_maintenance`, { name: row.name, is_maintaining: newVal }) // 🔴 修改 6: 使用 request
request.post('/api/toggle_maintenance', { name: row.name, is_maintaining: newVal })
.then(() => { row.is_maintaining = newVal; fetchData(); ElMessage.success(newVal ? '已进入维修模式' : '已恢复'); resolve(true) }) .then(() => { row.is_maintaining = newVal; fetchData(); ElMessage.success(newVal ? '已进入维修模式' : '已恢复'); resolve(true) })
.catch(() => { ElMessage.error('操作失败'); resolve(false) }) .catch(() => { ElMessage.error('操作失败'); resolve(false) })
}) })
@ -321,7 +352,8 @@ const handleMaintenanceBeforeChange = (row) => {
const toggleHidden = async (row, targetState) => { const toggleHidden = async (row, targetState) => {
try { try {
await axios.post(`${API_BASE}/api/toggle_hidden`, { name: row.name, is_hidden: targetState }) // 🔴 修改 7: 使用 request
await request.post('/api/toggle_hidden', { name: row.name, is_hidden: targetState })
row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复') row.is_hidden = targetState; fetchData(); ElMessage.success(targetState ? '已隐藏' : '已恢复')
} catch (e) { ElMessage.error('操作失败') } } catch (e) { ElMessage.error('操作失败') }
} }
@ -341,6 +373,7 @@ const updateDimensions = () => {
} }
onMounted(() => { onMounted(() => {
userRole.value = localStorage.getItem('role') || 'client'
fetchData() fetchData()
window.addEventListener('resize', updateDimensions) window.addEventListener('resize', updateDimensions)
}) })
@ -349,9 +382,8 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
<style scoped> <style scoped>
.dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; } .dashboard-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; }
.main-card { border-radius: 8px; overflow: visible; } /* overflow visible 确保下拉框不被遮挡 */ .main-card { border-radius: 8px; overflow: visible; }
/* 头部布局:默认 flex手机端会自动调整 */
.header-row { .header-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -363,11 +395,9 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
.left-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .left-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* 状态标签区 */
.status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; } .status-summary { margin-bottom: 15px; display: flex; gap: 5px; flex-wrap: wrap; }
.legend-tag { font-weight: bold; border: none; font-size: 12px; } .legend-tag { font-weight: bold; border: none; font-size: 12px; }
/* 工具栏区域 */
.toolbar { .toolbar {
background: #fff; background: #fff;
padding: 10px; padding: 10px;
@ -379,11 +409,10 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
flex-wrap: wrap; /* 允许换行 */ flex-wrap: wrap;
} }
.search-input { width: 220px; transition: width 0.3s; } .search-input { width: 220px; transition: width 0.3s; }
/* 表格内元素 */
.device-name-wrapper { display: flex; align-items: center; gap: 5px; cursor: pointer; } .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-wrapper:hover .device-name { color: #409EFF; text-decoration: underline; }
.device-name { font-weight: bold; font-size: 14px; color: #303133; } .device-name { font-weight: bold; font-size: 14px; color: #303133; }
@ -397,36 +426,23 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
.display-cell { cursor: pointer; padding: 4px 0; display: flex; align-items: center; justify-content: space-between; } .display-cell { cursor: pointer; padding: 4px 0; display: flex; align-items: center; justify-content: space-between; }
.edit-icon { color: #409EFF; margin-left: 5px; } .edit-icon { color: #409EFF; margin-left: 5px; }
/* 颜色行样式 */
:deep(.error-row) { background-color: #fef0f0 !important; } :deep(.error-row) { background-color: #fef0f0 !important; }
:deep(.warning-row) { background-color: #fdf6ec !important; } :deep(.warning-row) { background-color: #fdf6ec !important; }
:deep(.maintenance-row) { background-color: #f0f9ff !important; } :deep(.maintenance-row) { background-color: #f0f9ff !important; }
:deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; } :deep(.hidden-row) { background-color: #f4f4f5 !important; color: #909399; }
/* --- 📱 移动端适配专用 CSS --- */
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.dashboard-container { padding: 5px; } .dashboard-container { padding: 5px; }
/* 标题和状态堆叠 */
.left-panel { width: 100%; justify-content: space-between; margin-bottom: 5px; } .left-panel { width: 100%; justify-content: space-between; margin-bottom: 5px; }
.sys-title { font-size: 18px; } .sys-title { font-size: 18px; }
/* 按钮组撑满 */
.header-actions { width: 100%; justify-content: space-between; } .header-actions { width: 100%; justify-content: space-between; }
.header-actions .el-button { flex: 1; margin-left: 5px !important; margin-right: 5px !important; } .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; } .divider-mobile { width: 1px; height: 20px; background: #dcdfe6; margin: 0 5px; }
/* 搜索框独占一行 */
.filter-section { justify-content: space-between; } .filter-section { justify-content: space-between; }
.el-radio-group { width: 100%; display: flex; } .el-radio-group { width: 100%; display: flex; }
.el-radio-button { flex: 1; } .el-radio-button { flex: 1; }
:deep(.el-radio-button__inner) { width: 100%; padding: 8px 0; } :deep(.el-radio-button__inner) { width: 100%; padding: 8px 0; }
.search-input { width: 100%; margin-top: 5px; } .search-input { width: 100%; margin-top: 5px; }
/* 隐藏非关键按钮文字,节省空间 */
.el-button [class*="el-icon"] + span { display: inline-block; } .el-button [class*="el-icon"] + span { display: inline-block; }
} }
</style> </style>

View File

@ -65,37 +65,36 @@
<script setup> <script setup>
import { ref, nextTick, onBeforeUnmount } from 'vue' import { ref, nextTick, onBeforeUnmount } from 'vue'
import axios from 'axios' // 🔴 修改 1: 引入 request 替代 axios
import request from '../utils/request'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { ElMessage, ElConfigProvider } from 'element-plus' import { ElMessage, ElConfigProvider } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue' import { Refresh } from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' // 引入中文语言包 import zhCn from 'element-plus/es/locale/lang/zh-cn'
// --- 状态定义 --- // --- 状态定义 ---
const visible = ref(false) const visible = ref(false)
const loading = ref(false) const loading = ref(false)
const deviceName = ref('') const deviceName = ref('')
const currentSource = ref('') // 核心:保存设备源类型 (106 或 82) const currentSource = ref('')
const selectedDate = ref('') // 当前选择的日期 (YYYY-MM-DD) const selectedDate = ref('')
const dataTimestamp = ref('') // 用于在标题旁显示具体的时分秒 const dataTimestamp = ref('')
const chartModules = ref([]) const chartModules = ref([])
const emptyText = ref('暂无数据') const emptyText = ref('暂无数据')
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : ''
// 🔴 修改 2: 删除 API_BASE
// ECharts 实例管理 // ECharts 实例管理
let chartInstances = [] let chartInstances = []
const chartRefs = ref([]) const chartRefs = ref([])
const setChartRef = (el, index) => { if (el) chartRefs.value[index] = el } const setChartRef = (el, index) => { if (el) chartRefs.value[index] = el }
// 禁止选择未来日期
const disabledDate = (time) => { const disabledDate = (time) => {
return time.getTime() > Date.now() return time.getTime() > Date.now()
} }
// 格式化设备名称
const formatDisplayName = (name) => (name ? name.toUpperCase().replace(/_/g, ' ') : '') const formatDisplayName = (name) => (name ? name.toUpperCase().replace(/_/g, ' ') : '')
// 辅助函数:获取今天日期的字符串
const getTodayString = () => { const getTodayString = () => {
const today = new Date() const today = new Date()
const y = today.getFullYear() const y = today.getFullYear()
@ -111,14 +110,8 @@ const open = (row) => {
currentSource.value = row.source currentSource.value = row.source
chartModules.value = [] chartModules.value = []
// --- 逻辑修改核心:默认展示数据库最新一条数据 ---
// row.latest_time 格式通常为 "2026-01-08 16:29:28"
if (row.latest_time && row.latest_time !== 'N/A') { if (row.latest_time && row.latest_time !== 'N/A') {
// 1. 保存完整时间用于显示
dataTimestamp.value = row.latest_time dataTimestamp.value = row.latest_time
// 2. 提取日期部分 (YYYY-MM-DD) 赋值给 DatePicker
// 兼容空格分隔或 T 分隔
try { try {
const datePart = row.latest_time.split(' ')[0].split('T')[0] const datePart = row.latest_time.split(' ')[0].split('T')[0]
selectedDate.value = datePart selectedDate.value = datePart
@ -127,18 +120,14 @@ const open = (row) => {
selectedDate.value = getTodayString() selectedDate.value = getTodayString()
} }
} else { } else {
// 如果没有历史记录,默认显示今天,且不显示具体时间点
selectedDate.value = getTodayString() selectedDate.value = getTodayString()
dataTimestamp.value = '' dataTimestamp.value = ''
} }
// 3. 此时 selectedDate 已自动同步为最新数据的日期,直接加载
loadData() loadData()
} }
// 日期改变时重新加载
const handleDateChange = () => { const handleDateChange = () => {
// 用户手动切换日期时,清空具体时间显示(因为我们只知道日期,不知道该日期的具体时间点)
dataTimestamp.value = '' dataTimestamp.value = ''
loadData() loadData()
} }
@ -150,11 +139,11 @@ const loadData = async () => {
loading.value = true loading.value = true
chartModules.value = [] chartModules.value = []
emptyText.value = '加载中...' emptyText.value = '加载中...'
disposeCharts() // 销毁旧图表实例 disposeCharts()
try { try {
// 发起请求:根据设备名和日期获取数据 // 🔴 修改 3: 使用 request.get去除 API_BASE
const res = await axios.get(`${API_BASE}/api/device_data_by_date`, { const res = await request.get('/api/device_data_by_date', {
params: { params: {
name: deviceName.value, name: deviceName.value,
date: selectedDate.value date: selectedDate.value
@ -163,14 +152,11 @@ const loadData = async () => {
const { content, source } = res.data const { content, source } = res.data
// [关键容错] 优先使用接口返回的 source若接口未返回则使用列表页传来的 source
// 这决定了是使用 106正则解析 还是 82JSON解析
const effectiveSource = source || currentSource.value const effectiveSource = source || currentSource.value
if (!content || content === '{}' || content === 'null') { if (!content || content === '{}' || content === 'null') {
emptyText.value = `${selectedDate.value} 无数据记录` emptyText.value = `${selectedDate.value} 无数据记录`
} else { } else {
// 解析数据
const modules = parseChartData({ const modules = parseChartData({
name: deviceName.value, name: deviceName.value,
content, content,
@ -182,7 +168,6 @@ const loadData = async () => {
if (modules.length === 0) { if (modules.length === 0) {
emptyText.value = '数据解析失败 (格式不匹配)' emptyText.value = '数据解析失败 (格式不匹配)'
} else { } else {
// 等待 DOM 更新后渲染图表
await nextTick() await nextTick()
initCharts() initCharts()
} }
@ -192,32 +177,26 @@ const loadData = async () => {
emptyText.value = `${selectedDate.value} 无数据记录` emptyText.value = `${selectedDate.value} 无数据记录`
} else { } else {
console.error('Data Load Error:', e) console.error('Data Load Error:', e)
ElMessage.error('获取详细数据失败') // request.js 会处理通用错误,这里可以额外提示业务层面的失败
emptyText.value = '请求出错'
} }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// --- 数据解析逻辑 --- // --- 数据解析逻辑 (保持不变) ---
// 1. 解析 106 类型数据 (正则解析)
function parse106Data(content) { function parse106Data(content) {
if (typeof content !== 'string') return [] if (typeof content !== 'string') return []
const modules = [] const modules = []
// 匹配 Model, SN 和 波长信息
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let match let match
while ((match = infoRegex.exec(content)) !== null) { while ((match = infoRegex.exec(content)) !== null) {
const model = match[1] const model = match[1]
const sn = match[2] const sn = match[2]
// 处理波长数组
const wavelengths = match[3].split(',').map(Number).filter((n) => !isNaN(n)) const wavelengths = match[3].split(',').map(Number).filter((n) => !isNaN(n))
const series = [] const series = []
// 提取 P1 到 P4 的数据
for (let p = 1; p <= 4; p++) { for (let p = 1; p <= 4; p++) {
const dMatch = content.match( const dMatch = content.match(
new RegExp(`${model.trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i') new RegExp(`${model.trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
@ -240,11 +219,9 @@ function parse106Data(content) {
return modules return modules
} }
// 2. 解析 82 类型数据 (JSON解析)
function parse82Data(content, deviceName) { function parse82Data(content, deviceName) {
try { try {
const d = typeof content === 'string' ? JSON.parse(content) : content const d = typeof content === 'string' ? JSON.parse(content) : content
// 兼容 wavelenth 和 wavelength 拼写
if (d && (d.wavelenth || d.wavelength)) { if (d && (d.wavelenth || d.wavelength)) {
const xData = d.wavelenth || d.wavelength const xData = d.wavelenth || d.wavelength
return [{ return [{
@ -264,7 +241,6 @@ function parse82Data(content, deviceName) {
} }
} }
// 3. 统一解析入口
function parseChartData(device) { function parseChartData(device) {
if (!device || !device.content) return [] if (!device || !device.content) return []
const is106Site = device.source && device.source.includes('106') const is106Site = device.source && device.source.includes('106')
@ -276,7 +252,7 @@ function parseChartData(device) {
} }
} }
// --- ECharts 渲染逻辑 --- // --- ECharts 渲染逻辑 (保持不变) ---
function getChartOption(moduleData, isMobile = false) { function getChartOption(moduleData, isMobile = false) {
const titleText = moduleData.type === '106' const titleText = moduleData.type === '106'
@ -309,7 +285,6 @@ const initCharts = () => {
chartModules.value.forEach((mod, index) => { chartModules.value.forEach((mod, index) => {
const el = chartRefs.value[index] const el = chartRefs.value[index]
if (el) { if (el) {
// 防止重复初始化
if (echarts.getInstanceByDom(el)) echarts.getInstanceByDom(el).dispose() if (echarts.getInstanceByDom(el)) echarts.getInstanceByDom(el).dispose()
const chart = echarts.init(el) const chart = echarts.init(el)
chart.setOption(getChartOption(mod, isMobile)) chart.setOption(getChartOption(mod, isMobile))

View File

@ -137,12 +137,13 @@
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import axios from 'axios' // 🔴 修改 1: 引入 request
import request from '../utils/request'
import { ElMessage, ElConfigProvider } from 'element-plus' import { ElMessage, ElConfigProvider } from 'element-plus'
import { Search, Plus, Delete, Edit, InfoFilled } from '@element-plus/icons-vue' import { Search, Plus, Delete, Edit, InfoFilled } from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
const API_BASE = import.meta.env.DEV ? 'http://127.0.0.1:5000' : '' // 🔴 修改 2: 删除 API_BASE
// --- 核心状态 --- // --- 核心状态 ---
const visible = ref(false) const visible = ref(false)
@ -167,10 +168,8 @@ const logDialog = reactive({
// --- 方法逻辑 --- // --- 方法逻辑 ---
// 1. 暴露给父组件的打开方法
const open = (prefillData = null) => { const open = (prefillData = null) => {
visible.value = true visible.value = true
// 如果从设备卡片点击进来,自动筛选该设备
if (prefillData && prefillData.deviceName) { if (prefillData && prefillData.deviceName) {
keyword.value = prefillData.deviceName keyword.value = prefillData.deviceName
} }
@ -186,7 +185,8 @@ const fetchLogs = async () => {
params.start_date = dateRange.value[0] params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1] params.end_date = dateRange.value[1]
} }
const res = await axios.get(`${API_BASE}/api/logs/list`, { params }) // 🔴 修改 3: 使用 request.get
const res = await request.get('/api/logs/list', { params })
logsList.value = res.data.data logsList.value = res.data.data
} catch (e) { } catch (e) {
ElMessage.error('加载日志中心数据失败') ElMessage.error('加载日志中心数据失败')
@ -200,7 +200,6 @@ const openAddDialog = () => {
logDialog.isEdit = false logDialog.isEdit = false
logDialog.form = { logDialog.form = {
id: null, id: null,
// 自动带入当前的搜索词作为设备名,提高录入效率
device_name: keyword.value || '', device_name: keyword.value || '',
engineer: '', engineer: '',
location: '', location: '',
@ -222,7 +221,7 @@ const openEditDialog = (row) => {
logDialog.visible = true logDialog.visible = true
} }
// 5. 提交表单(核心逻辑区分) // 5. 提交表单
const submitLog = async () => { const submitLog = async () => {
if (!logDialog.form.device_name || !logDialog.form.content) { if (!logDialog.form.device_name || !logDialog.form.content) {
return ElMessage.warning('设备名称和事件内容为必填项') return ElMessage.warning('设备名称和事件内容为必填项')
@ -231,11 +230,12 @@ const submitLog = async () => {
logDialog.submitting = true logDialog.submitting = true
try { try {
const endpoint = logDialog.isEdit ? '/api/logs/update' : '/api/logs/add' const endpoint = logDialog.isEdit ? '/api/logs/update' : '/api/logs/add'
await axios.post(`${API_BASE}${endpoint}`, logDialog.form) // 🔴 修改 4: 使用 request.post
await request.post(endpoint, logDialog.form)
ElMessage.success(logDialog.isEdit ? '日志已成功修改' : '日志已添加') ElMessage.success(logDialog.isEdit ? '日志已成功修改' : '日志已添加')
logDialog.visible = false logDialog.visible = false
fetchLogs() // 刷新列表 fetchLogs()
} catch (e) { } catch (e) {
ElMessage.error('操作失败,请检查网络或后端服务') ElMessage.error('操作失败,请检查网络或后端服务')
} finally { } finally {
@ -246,7 +246,8 @@ const submitLog = async () => {
// 6. 删除逻辑 // 6. 删除逻辑
const deleteLog = async (id) => { const deleteLog = async (id) => {
try { try {
await axios.post(`${API_BASE}/api/logs/delete`, { id }) // 🔴 修改 5: 使用 request.post
await request.post('/api/logs/delete', { id })
ElMessage.success('记录已安全删除') ElMessage.success('记录已安全删除')
fetchLogs() fetchLogs()
} catch (e) { } catch (e) {
@ -254,10 +255,8 @@ const deleteLog = async (id) => {
} }
} }
// 格式化名称工具
const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : '' const formatDisplayName = (name) => name ? name.toUpperCase().replace(/_/g, ' ') : ''
// 暴露方法给父组件 Dashboard 调用
defineExpose({ open }) defineExpose({ open })
</script> </script>
@ -290,7 +289,6 @@ defineExpose({ open })
gap: 4px; gap: 4px;
} }
/* 调整输入框禁用时的样式,保持可读性 */
:deep(.el-input.is-disabled .el-input__wrapper) { :deep(.el-input.is-disabled .el-input__wrapper) {
background-color: #f5f7fa; background-color: #f5f7fa;
box-shadow: 0 0 0 1px #e4e7ed inset; box-shadow: 0 0 0 1px #e4e7ed inset;

View File

@ -0,0 +1,192 @@
<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="showCreateModal = true">新建客户</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" />
<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">
<template #default="{ row }">
<el-tag>{{ row.allowed_device_ids?.length || 0 }} </el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link icon="Setting" @click="openPermissionModal(row)">分配权限</el-button>
<el-popconfirm title="确定删除该客户吗?" @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>
<template #footer>
<span class="dialog-footer">
<el-button @click="showCreateModal = false">取消</el-button>
<el-button type="primary" @click="createClient">确认创建</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="showPermissionModal" :title="`给 ${currentUser?.username} 分配设备`" width="600px">
<div class="permission-transfer">
<el-transfer
v-model="selectedDeviceIds"
:data="allDevices"
:titles="['可选设备', '已授权设备']"
:props="{ key: 'id', label: 'label' }"
filterable
filter-placeholder="搜索设备"
/>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="showPermissionModal = false">取消</el-button>
<el-button type="primary" @click="savePermissions">保存设置</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
// 🔴 修改 1: 引入 request
import request from '../utils/request'
import { ElMessage } from 'element-plus'
import { Back, Plus, Setting, Delete } from '@element-plus/icons-vue'
const router = useRouter()
// 🔴 修改 2: 删除 API_BASE
const loading = ref(false)
const users = ref([])
const rawDevices = ref([]) // 原始设备列表
const showCreateModal = ref(false)
const showPermissionModal = ref(false)
const newUser = ref({ username: '', password: '' })
const currentUser = ref(null)
const selectedDeviceIds = ref([])
// 转换设备数据供穿梭框使用
const allDevices = computed(() => {
return rawDevices.value.map(d => ({
id: d.id,
label: `${d.name} (${d.install_site || '未命名'})`
}))
})
onMounted(async () => {
await fetchUsers()
await fetchAllDevices()
})
const fetchUsers = async () => {
loading.value = true
try {
// 🔴 修改 3: 使用 request.get移除 headers
const res = await request.get('/api/admin/users')
users.value = res.data
} catch (e) {
// 拦截器已处理 401/403这里只处理通用错误提示
console.error(e)
} finally {
loading.value = false
}
}
const fetchAllDevices = async () => {
try {
// 🔴 修改 4: 使用 request.get复用 dashboard 接口
const res = await request.get('/api/devices_overview')
const list = res.data.data || res.data
rawDevices.value = list
} catch (e) {
console.error(e)
}
}
const createClient = async () => {
if (!newUser.value.username || !newUser.value.password) return ElMessage.warning('请填写完整')
try {
// 🔴 修改 5: 使用 request.post
await request.post('/api/admin/create_client', newUser.value)
ElMessage.success('创建成功')
showCreateModal.value = false
newUser.value = { username: '', password: '' }
fetchUsers()
} catch (e) {
ElMessage.error(e.response?.data?.msg || '创建失败')
}
}
const openPermissionModal = (user) => {
currentUser.value = user
// 回显已选权限
selectedDeviceIds.value = user.allowed_device_ids || []
showPermissionModal.value = true
}
const savePermissions = async () => {
try {
// 🔴 修改 6: 使用 request.post
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) => {
ElMessage.info('删除功能暂需后端接口支持')
}
</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; }
:deep(.el-transfer-panel) { width: 220px; }
@media screen and (max-width: 768px) {
:deep(.el-transfer-panel) { width: 100%; margin-bottom: 10px; }
.permission-transfer { display: flex; flex-direction: column; }
}
</style>

View File

@ -19,7 +19,8 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import axios from 'axios' // 🔴 修改点:引入封装好的 request而不是 axios
import request from '../utils/request'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@ -32,16 +33,35 @@ const handleLogin = async () => {
loading.value = true loading.value = true
try { try {
const res = await axios.post('/api/login', loginForm.value) // 🔴 使用 request 发送请求
if (res.data.code === 200) { 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('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('欢迎回来') ElMessage.success('欢迎回来')
router.push('/dashboard') // 登录成功跳转 router.push('/dashboard')
} else {
ElMessage.error(data.message || '登录失败')
} }
} catch (error) { } catch (error) {
ElMessage.error(error.response?.data?.message || '登录失败') console.error(error)
// request.js 里已经拦截了一部分错误,这里只需处理 loading
} finally { } finally {
loading.value = false loading.value = false
} }