2.3权限管理,基本盘完成,下一步修改设备管理弹窗设计,完善工程师日志写入设计

This commit is contained in:
YueL1331
2026-01-09 17:22:12 +08:00
parent c416c8ad07
commit ca03816668
8 changed files with 644 additions and 268 deletions

View File

@ -164,7 +164,7 @@ def create_app():
func=auto_monitor_job, func=auto_monitor_job,
args=[app], args=[app],
trigger='cron', trigger='cron',
hour=10, hour=12,
minute=0 minute=0
) )
if not scheduler.running: if not scheduler.running:

62
2_3banben/init_db.py Normal file
View File

@ -0,0 +1,62 @@
import os
from app import create_app
from extensions import db
from models import User, Device, UserDevicePermission
# 创建应用实例
app = create_app()
def init_db():
with app.app_context():
# ==========================================
# ⚠️ 警告:这会清空现有的数据库表结构并重建
# 如果只想更新 User 表,可以注释掉 db.drop_all()
# 但因为增加了字段,直接重建是最稳妥的。
# ==========================================
print("正在清理旧数据库...")
db.drop_all()
print("正在创建新表结构...")
db.create_all()
print("✅ 数据库表结构创建完成 (devices.db 和 users.db)")
# ==========================================
# 🟢 1. 创建超级管理员 (Root)
# 即使代码里有后门,数据库里有一个对应的实体也是最好的
# ==========================================
admin = User(username='admin', role='admin')
admin.set_password('licahk') # 设置密码
db.session.add(admin)
print(f"👤 用户创建: [admin] (角色: 超级管理员)")
# ==========================================
# 🟡 2. 创建一个测试工程师 (可选)
# ==========================================
engineer = User(username='engineer01', role='engineer')
engineer.set_password('123456')
db.session.add(engineer)
print(f"👤 用户创建: [engineer01] (角色: 工程师)")
# ==========================================
# ⚪ 3. 创建一个测试普通客户 (可选)
# ==========================================
client = User(username='client01', role='client')
client.set_password('123456')
db.session.add(client)
print(f"👤 用户创建: [client01] (角色: 客户)")
# 提交更改
db.session.commit()
print("\n🚀 初始化完成!请运行 run.py 启动服务器。")
if __name__ == '__main__':
# 再次确认防止误删
print("此操作会删除现有的 'users.db''devices.db' 中的数据并重建。")
confirm = input("确认继续吗? (y/n): ")
if confirm.lower() == 'y':
init_db()
else:
print("操作已取消。")

View File

@ -1,9 +1,12 @@
from datetime import datetime from datetime import datetime
# 引入 UserMixin 是 Flask 标准做法,虽然你主要用 JWT但保留它可以方便未来扩展或使用某些 Flask 插件
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from extensions import db from extensions import db
# ======================= # =======================
# 数据库 1: 业务数据 (devices.db) # 数据库 1: 业务数据 (默认数据库 / devices.db)
# ======================= # =======================
class Device(db.Model): class Device(db.Model):
@ -14,29 +17,33 @@ class Device(db.Model):
name = db.Column(db.String(100), unique=True, index=True) name = db.Column(db.String(100), unique=True, index=True)
source = db.Column(db.String(50)) source = db.Column(db.String(50))
# 快照字段(爬虫更新) # 快照字段(爬虫自动更新)
status = db.Column(db.String(50)) status = db.Column(db.String(50))
current_value = db.Column(db.String(200)) current_value = db.Column(db.String(200))
latest_time = db.Column(db.String(50)) latest_time = db.Column(db.String(50))
json_data = db.Column(db.Text) json_data = db.Column(db.Text) # 存储完整原始JSON
check_time = db.Column(db.String(50)) check_time = db.Column(db.String(50))
reason = db.Column(db.String(255)) reason = db.Column(db.String(255))
offset = db.Column(db.String(50)) offset = db.Column(db.String(50)) # 时间偏移量说明
# 手动录入字段 # 手动录入字段 (管理员/工程师可改)
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):
"""
转换为前端友好的字典格式
"""
# 简单处理状态:只要不是明确的离线/异常,就视为 online
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,
'name': self.name, 'name': self.name,
'source': self.source, 'source': self.source,
'latest_time': self.latest_time, 'latest_time': self.latest_time,
'status': api_status, 'status': api_status, # 给前端图标用的状态 (online/offline)
'status_text': self.status, 'status_text': self.status, # 显示在界面的原始状态文字
'value': self.current_value, 'value': self.current_value,
'reason': self.reason, 'reason': self.reason,
'install_site': self.install_site or '', 'install_site': self.install_site or '',
@ -45,21 +52,26 @@ class Device(db.Model):
'offset': self.offset 'offset': self.offset
} }
class DeviceHistory(db.Model): class DeviceHistory(db.Model):
__tablename__ = 'device_history' __tablename__ = 'device_history'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# 这里虽有 ForeignKey但在 SQLite 中如果不开启外键约束检查通常没事
# 但建议逻辑上视为级联删除
device_id = db.Column(db.Integer, db.ForeignKey('devices.id')) device_id = db.Column(db.Integer, db.ForeignKey('devices.id'))
data_time = db.Column(db.String(50)) data_time = db.Column(db.String(50))
status = db.Column(db.String(50)) status = db.Column(db.String(50))
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):
__tablename__ = 'maintenance_logs' __tablename__ = 'maintenance_logs'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
device_name = db.Column(db.String(100), nullable=False) device_name = db.Column(db.String(100), nullable=False)
engineer = db.Column(db.String(50)) engineer = db.Column(db.String(50))
@ -77,18 +89,23 @@ class MaintenanceLog(db.Model):
'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S') 'timestamp': self.timestamp.strftime('%Y-%m-%d %H:%M:%S')
} }
# ======================= # =======================
# 数据库 2: 用户管理 (users.db) # 数据库 2: 用户管理 (users.db)
# ======================= # =======================
class User(db.Model): class User(UserMixin, db.Model):
__bind_key__ = 'users_db' # 关键:指定存储在 users.db __bind_key__ = 'users_db' # 关键:指定存储在 users.db
__tablename__ = 'users' __tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128)) password_hash = db.Column(db.String(128))
role = db.Column(db.String(20), default='client') # 'admin' or 'client'
# 🟢 核心字段:角色权限
# 可选值: 'admin' (超管), 'engineer' (工程师), 'client' (客户)
role = db.Column(db.String(20), default='client')
created_at = db.Column(db.DateTime, default=datetime.now) created_at = db.Column(db.DateTime, default=datetime.now)
def set_password(self, password): def set_password(self, password):
@ -97,6 +114,7 @@ class User(db.Model):
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
class UserDevicePermission(db.Model): class UserDevicePermission(db.Model):
""" """
关联表存储用户ID和设备ID的对应关系。 关联表存储用户ID和设备ID的对应关系。
@ -107,5 +125,7 @@ class UserDevicePermission(db.Model):
__tablename__ = 'user_device_permissions' __tablename__ = 'user_device_permissions'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# 关联 User 表 (同库)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
device_id = db.Column(db.Integer, nullable=False) # 对应 devices.db 中的 Device.id # 关联 Device 表 (异库,只存 ID)
device_id = db.Column(db.Integer, nullable=False)

View File

@ -3,13 +3,13 @@ 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 # 引入 jwt 相关函数
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity 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, User, UserDevicePermission from models import Device, DeviceHistory, MaintenanceLog, User, UserDevicePermission
# 尝试导入爬虫模块 # 尝试导入爬虫模块 (如果没有则跳过,防止报错)
try: try:
from services.core import execute_monitor_task from services.core import execute_monitor_task
except ImportError: except ImportError:
@ -19,28 +19,42 @@ api_bp = Blueprint('api', __name__, url_prefix='/api')
# ======================= # =======================
# 🔧 辅助函数 # 🔧 辅助函数 (权限核心)
# ======================= # =======================
def is_admin(user_id): def is_admin(user_id):
""" """
判断用户权限 判断是否为超级管理员 (Root权限)
兼容 Token 中存储的 String 类型 ID 逻辑:
1. ID 为 '0' (硬编码后门) -> 通过
2. 数据库中角色为 'admin' -> 通过
""" """
# 1. 既然 Token 里是字符串,这里必须转成字符串比较,或者转成 int 比较 if str(user_id) == '0':
if str(user_id) == '0': return True return True
if not user_id:
if not user_id: return False return False
try: try:
# 2. 查库时需要 int u = User.query.get(int(user_id))
uid = int(user_id)
u = User.query.get(uid)
return u and u.role == 'admin' return u and u.role == 'admin'
except: except:
return False return False
def is_manager(user_id):
"""
判断是否为管理者 (Admin OR Engineer)
用于:修改地点、切换维修模式、写日志
"""
if is_admin(user_id):
return True
try:
u = User.query.get(int(user_id))
return u and u.role == 'engineer'
except:
return False
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:
clean = str(latest_time_str).split()[0].replace('_', '-') clean = str(latest_time_str).split()[0].replace('_', '-')
@ -60,30 +74,21 @@ def login():
username = data.get('username') username = data.get('username')
password = data.get('password') password = data.get('password')
# 1. 查库登录 # 1. 后门判定
user = User.query.filter_by(username=username).first() if username == 'admin' and password == 'licahk':
if user and user.check_password(password): token = create_access_token(identity='0', additional_claims={'role': 'admin'})
# 🟢 修复核心:必须用 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({ return jsonify({
'code': 200, 'message': '登录成功', 'code': 200, 'message': 'Root后门登录',
'token': token, 'role': user.role, 'user_id': user.id 'token': token, 'role': 'admin', 'user_id': 0, 'username': 'admin'
}) })
# 2. 硬编码后门 (修正:生成真实 Token) # 2. 正常查库登录
if username == 'admin' and password == 'licahk': user = User.query.filter_by(username=username).first()
# 🟢 修复核心:'0' 必须是字符串 if user and user.check_password(password):
token = create_access_token( token = create_access_token(identity=str(user.id), additional_claims={'role': user.role})
identity='0',
additional_claims={'role': 'admin'}
)
return jsonify({ return jsonify({
'code': 200, 'message': 'Root登录', 'code': 200, 'message': '登录成功',
'token': token, 'role': 'admin', 'user_id': 0 'token': token, 'role': user.role, 'user_id': user.id, 'username': user.username
}) })
return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401 return jsonify({'code': 401, 'message': '用户名或密码错误'}), 401
@ -96,29 +101,16 @@ def login():
@jwt_required() @jwt_required()
def devices_overview(): def devices_overview():
try: try:
# 获取到的 user_id 现在是字符串类型 (例如 "1" 或 "0")
user_id = get_jwt_identity() user_id = get_jwt_identity()
target_devices = []
if is_admin(user_id): if is_admin(user_id):
target_devices = Device.query.all() target_devices = Device.query.all()
else: else:
# 普通用户权限查询 (users_db) perms = UserDevicePermission.query.filter_by(user_id=int(user_id)).all()
# 必须转回 int 才能去数据库查 ID allowed_ids = [p.device_id for p in perms]
try: target_devices = Device.query.filter(Device.id.in_(allowed_ids)).all() if allowed_ids else []
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]}) return jsonify({'code': 200, 'data': [d.to_dict() for d in target_devices]})
except Exception as e: except Exception as e:
print(f"Error in devices_overview: {e}")
return jsonify({'code': 500, 'message': str(e)}) return jsonify({'code': 500, 'message': str(e)})
@ -127,108 +119,121 @@ def devices_overview():
def device_data_by_date(): def device_data_by_date():
name = request.args.get('name') name = request.args.get('name')
date_str = request.args.get('date') date_str = request.args.get('date')
if not name or not date_str: return jsonify({'code': 400}), 400
if not name or not date_str:
return jsonify({'code': 400, 'message': 'Missing params'}), 400
device = Device.query.filter_by(name=name).first() device = Device.query.filter_by(name=name).first()
if not device: return jsonify({'code': 404, 'message': 'Device not found'}), 404 if not device: return jsonify({'code': 404}), 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()
# 优先查历史 content = hist.json_data if hist else (device.json_data if device.latest_time and str(device.latest_time).startswith(date_str) else 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: if content:
# 尝试转JSON对象返回 if isinstance(content, str):
try: try: content = json.loads(content)
if isinstance(content, str): content = json.loads(content) except: pass
except:
pass
return jsonify({'code': 200, 'name': device.name, 'source': device.source, 'content': content}) return jsonify({'code': 200, 'name': device.name, 'source': device.source, 'content': content})
return jsonify({'code': 404, 'message': '无数据'}), 404 return jsonify({'code': 404, 'message': '无数据'}), 404
# ======================= # =======================
# 2. 用户管理 (Admin) # 2. 用户管理 (Admin Only)
# ======================= # =======================
@api_bp.route('/admin/users', methods=['GET']) @api_bp.route('/admin/users', methods=['GET'])
@jwt_required() @jwt_required()
def admin_get_users(): def admin_get_users():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
users = User.query.order_by(desc(User.created_at)).all()
clients = User.query.filter_by(role='client').all()
result = [] result = []
for c in clients: for u in users:
perms = UserDevicePermission.query.filter_by(user_id=c.id).all() if str(u.id) == str(get_jwt_identity()): continue
perms = UserDevicePermission.query.filter_by(user_id=u.id).all()
result.append({ result.append({
"id": c.id, "username": c.username, "created_at": c.created_at, "id": u.id, "username": u.username, "role": u.role,
"allowed_device_ids": [p.device_id for p in perms] "created_at": u.created_at, "allowed_device_ids": [p.device_id for p in perms]
}) })
return jsonify(result) return jsonify({'code': 200, 'data': result})
@api_bp.route('/admin/create_client', methods=['POST']) @api_bp.route('/admin/create_user', methods=['POST'])
@jwt_required() @jwt_required()
def admin_create_client(): def admin_create_user():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json() data = request.get_json()
if User.query.filter_by(username=data['username']).first(): if User.query.filter_by(username=data.get('username')).first():
return jsonify({'code': 400, 'msg': '用户已存在'}), 400 return jsonify({'code': 400, 'msg': '用户已存在'}), 400
u = User(username=data.get('username'), role=data.get('role', 'client'))
u = User(username=data['username'], role='client') u.set_password(data.get('password'))
u.set_password(data['password'])
db.session.add(u) db.session.add(u)
db.session.commit() db.session.commit()
return jsonify({'code': 200, 'msg': '创建成功'}) return jsonify({'code': 200, 'msg': '创建成功'})
@api_bp.route('/admin/delete_user', methods=['POST'])
@jwt_required()
def admin_delete_user():
curr = get_jwt_identity()
if not is_admin(curr): return jsonify({'code': 403}), 403
uid = request.get_json().get('user_id')
if str(uid) == str(curr): return jsonify({'code': 400, 'msg': '无法删除自己'}), 400
user = User.query.get(uid)
if user:
UserDevicePermission.query.filter_by(user_id=user.id).delete()
db.session.delete(user)
db.session.commit()
return jsonify({'code': 200, 'msg': '删除成功'})
@api_bp.route('/admin/assign_devices', methods=['POST']) @api_bp.route('/admin/assign_devices', methods=['POST'])
@jwt_required() @jwt_required()
def admin_assign_devices(): def admin_assign_devices():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json() data = request.get_json()
uid = data.get('user_id') UserDevicePermission.query.filter_by(user_id=data.get('user_id')).delete()
d_ids = data.get('device_ids', []) for did in data.get('device_ids', []):
db.session.add(UserDevicePermission(user_id=data.get('user_id'), device_id=did))
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() db.session.commit()
return jsonify({'code': 200, 'msg': '权限已保存'}) return jsonify({'code': 200, 'msg': '权限已保存'})
# ======================= # =======================
# 3. 日志与工具 # 3. 日志与工具 (权限隔离)
# ======================= # =======================
@api_bp.route('/logs/list', methods=['GET']) @api_bp.route('/logs/list', methods=['GET'])
@jwt_required()
def get_logs(): def get_logs():
"""
获取日志列表
权限逻辑更新:
- Admin: 可以看所有
- Engineer/Client: 只能看自己名下设备的日志 (严格过滤)
"""
user_id = get_jwt_identity()
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
start_date = request.args.get('start_date') start_date = request.args.get('start_date')
end_date = request.args.get('end_date') end_date = request.args.get('end_date')
query = MaintenanceLog.query query = MaintenanceLog.query
# 🛡️ 权限隔离
if not is_admin(user_id):
perms = UserDevicePermission.query.filter_by(user_id=int(user_id)).all()
if not perms: return jsonify({'code': 200, 'data': []})
allowed_names = [d.name for d in Device.query.filter(Device.id.in_([p.device_id for p in perms])).all()]
query = query.filter(MaintenanceLog.device_name.in_(allowed_names))
if keyword: if keyword:
kw = f"%{keyword}%" kw = f"%{keyword}%"
query = query.filter(or_( query = query.filter(or_(
MaintenanceLog.device_name.like(kw), MaintenanceLog.engineer.like(kw), MaintenanceLog.device_name.like(kw), MaintenanceLog.engineer.like(kw),
MaintenanceLog.location.like(kw), MaintenanceLog.content.like(kw) MaintenanceLog.location.like(kw), MaintenanceLog.content.like(kw)
)) ))
if start_date and end_date: if start_date and end_date:
try: try:
s = datetime.strptime(start_date, '%Y-%m-%d') s = datetime.strptime(start_date, '%Y-%m-%d')
e = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59) e = datetime.strptime(end_date, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
query = query.filter(MaintenanceLog.timestamp.between(s, e)) query = query.filter(MaintenanceLog.timestamp.between(s, e))
except: except: pass
pass
logs = query.order_by(desc(MaintenanceLog.timestamp)).all() logs = query.order_by(desc(MaintenanceLog.timestamp)).all()
return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]}) return jsonify({'code': 200, 'data': [l.to_dict() for l in logs]})
@ -237,7 +242,9 @@ def get_logs():
@api_bp.route('/logs/add', methods=['POST']) @api_bp.route('/logs/add', methods=['POST'])
@jwt_required() @jwt_required()
def add_log(): def add_log():
if not is_manager(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json() data = request.get_json()
# 后端安全校验:如果是工程师,建议再次校验 engineer 字段是否匹配其 username
db.session.add(MaintenanceLog( db.session.add(MaintenanceLog(
device_name=data.get('device_name'), device_name=data.get('device_name'),
engineer=data.get('engineer'), engineer=data.get('engineer'),
@ -248,6 +255,21 @@ def add_log():
return jsonify({'code': 200}) return jsonify({'code': 200})
@api_bp.route('/logs/update', methods=['POST'])
@jwt_required()
def update_log():
if not is_manager(get_jwt_identity()): return jsonify({'code': 403}), 403
data = request.get_json()
log = MaintenanceLog.query.get(data.get('id'))
if not log: return jsonify({'code': 404}), 404
log.engineer = data.get('engineer')
log.location = data.get('location')
log.content = data.get('content')
db.session.commit()
return jsonify({'code': 200, 'msg': '更新成功'})
@api_bp.route('/logs/delete', methods=['POST']) @api_bp.route('/logs/delete', methods=['POST'])
@jwt_required() @jwt_required()
def delete_log(): def delete_log():
@ -260,16 +282,18 @@ def delete_log():
return jsonify({'code': 404}) return jsonify({'code': 404})
# =======================
# 4. 系统检测与控制 (原有功能完整保留)
# =======================
@api_bp.route('/run_monitor', methods=['POST']) @api_bp.route('/run_monitor', methods=['POST'])
@jwt_required() @jwt_required()
def run_monitor(): def run_monitor():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403
if not execute_monitor_task: if not execute_monitor_task: return jsonify({'code': 500, 'msg': '爬虫模块未加载'})
return jsonify({'code': 500, 'msg': '爬虫模块未加载'})
try: try:
task_result = execute_monitor_task() task_result = execute_monitor_task()
if not task_result: return jsonify({'code': 200, 'msg': '跳过'}) if not task_result: return jsonify({'code': 200, 'msg': '无任务'})
scraped_list = task_result.get('device_list', []) scraped_list = task_result.get('device_list', [])
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@ -279,25 +303,23 @@ def run_monitor():
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', {})
target_time = item.get('target_time') target_time = item.get('target_time')
source = item.get('source', '') 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', '')
match = re.search(r'/Data/(\d{4}_\d{2}_\d{2})/\w+_(\d{2}_\d{2}_\d{2})\.csv', path_str) match = re.search(r'/Data/(\d{4}_\d{2}_\d{2})/\w+_(\d{2}_\d{2}_\d{2})\.csv', path_str)
if match: if match:
target_time = f"{match.group(1).replace('_', '-')} {match.group(2).replace('_', ':')}" target_time = f"{match.group(1).replace('_', '-')} {match.group(2).replace('_', ':')}"
except: except: pass
pass
json_str = json.dumps(d_raw, ensure_ascii=False) 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)
db.session.add(device) db.session.add(device)
db.session.flush() db.session.flush()
@ -316,7 +338,7 @@ def run_monitor():
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)})
@ -325,7 +347,7 @@ def run_monitor():
@api_bp.route('/update_site', methods=['POST']) @api_bp.route('/update_site', methods=['POST'])
@jwt_required() @jwt_required()
def update_site(): def update_site():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 if not is_manager(get_jwt_identity()): return jsonify({'code': 403}), 403
d = Device.query.filter_by(name=request.get_json().get('name')).first() d = Device.query.filter_by(name=request.get_json().get('name')).first()
if d: if d:
d.install_site = request.get_json().get('site') d.install_site = request.get_json().get('site')
@ -337,7 +359,7 @@ def update_site():
@api_bp.route('/toggle_maintenance', methods=['POST']) @api_bp.route('/toggle_maintenance', methods=['POST'])
@jwt_required() @jwt_required()
def toggle_maintenance(): def toggle_maintenance():
if not is_admin(get_jwt_identity()): return jsonify({'code': 403}), 403 if not is_manager(get_jwt_identity()): return jsonify({'code': 403}), 403
d = Device.query.filter_by(name=request.get_json().get('name')).first() d = Device.query.filter_by(name=request.get_json().get('name')).first()
if d: if d:
d.is_maintaining = request.get_json().get('is_maintaining') d.is_maintaining = request.get_json().get('is_maintaining')

View File

@ -9,12 +9,15 @@
<el-tag type="info" effect="plain" round size="small"> <el-tag type="info" effect="plain" round size="small">
<el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }} <el-icon><Clock /></el-icon> 更新: {{ lastCheckTime || '...' }}
</el-tag> </el-tag>
<el-tag :type="roleTagType" effect="dark" round size="small" style="margin-left: 10px;">
{{ roleDisplayName }}
</el-tag>
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-button <el-button
v-if="userRole === 'admin'" v-if="isAdmin"
type="primary" type="primary"
plain plain
icon="Avatar" icon="Avatar"
@ -24,11 +27,20 @@
</el-button> </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>
<el-button type="warning" plain icon="RefreshRight" :loading="runningTask" @click="runManualMonitor">
检测 <el-button
v-if="isAdmin"
type="warning"
plain
icon="RefreshRight"
:loading="runningTask"
@click="runManualMonitor"
>
系统检测
</el-button> </el-button>
<el-button circle icon="Refresh" :loading="loading" @click="fetchData" /> <el-button circle icon="Refresh" :loading="loading" @click="fetchData" />
<div class="divider-mobile"></div> <div class="divider-mobile"></div>
@ -55,7 +67,7 @@
<el-radio-button label="abnormal" class="red-radio"> <el-radio-button label="abnormal" class="red-radio">
异常({{ summary.errorCount + summary.warningCount }}) 异常({{ summary.errorCount + summary.warningCount }})
</el-radio-button> </el-radio-button>
<el-radio-button label="hidden" class="gray-radio"> <el-radio-button v-if="isAdmin" label="hidden" class="gray-radio">
回收({{ summary.hiddenCount }}) 回收({{ summary.hiddenCount }})
</el-radio-button> </el-radio-button>
</el-radio-group> </el-radio-group>
@ -111,7 +123,7 @@
<el-table-column label="安装地点" min-width="160"> <el-table-column label="安装地点" min-width="160">
<template #default="{ row }"> <template #default="{ row }">
<div v-if="row.isEditingSite" class="editing-cell"> <div v-if="row.isEditingSite && canManageDevice" class="editing-cell">
<el-input <el-input
v-model="row.tempSite" v-model="row.tempSite"
size="small" size="small"
@ -121,9 +133,9 @@
placeholder="输入后回车" placeholder="输入后回车"
/> />
</div> </div>
<div v-else class="display-cell" @click="handleEditSite(row)"> <div v-else class="display-cell" @click="canManageDevice ? handleEditSite(row) : null" :style="{ cursor: canManageDevice ? 'pointer' : 'default' }">
<span>{{ row.install_site || '点击填写' }}</span> <span>{{ row.install_site || (canManageDevice ? '点击填写' : '-') }}</span>
<el-icon class="edit-icon"><EditPen /></el-icon> <el-icon v-if="canManageDevice" class="edit-icon"><EditPen /></el-icon>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@ -146,19 +158,23 @@
<template #default="{ row }"> <template #default="{ row }">
<div class="action-group"> <div class="action-group">
<template v-if="row.is_hidden"> <template v-if="row.is_hidden">
<el-button type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button> <el-button v-if="isAdmin" type="success" plain size="small" icon="RefreshLeft" @click="toggleHidden(row, false)">恢复</el-button>
</template> </template>
<template v-else> <template v-else>
<el-switch <el-switch
v-if="canManageDevice"
v-model="row.is_maintaining" v-model="row.is_maintaining"
inline-prompt inline-prompt
active-text="" active-text=""
inactive-text="" inactive-text=""
style="--el-switch-on-color: #409EFF;" style="--el-switch-on-color: #409EFF; margin-right: 8px;"
:before-change="() => handleMaintenanceBeforeChange(row)" :before-change="() => handleMaintenanceBeforeChange(row)"
/> />
<el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button> <el-button type="primary" link icon="Edit" @click="openLogCenter(row)">日志</el-button>
<el-popconfirm title="确定隐藏?" @confirm="toggleHidden(row, true)">
<el-popconfirm v-if="isAdmin" title="确定隐藏?" @confirm="toggleHidden(row, true)">
<template #reference> <template #reference>
<el-button type="danger" link icon="Delete">隐藏</el-button> <el-button type="danger" link icon="Delete">隐藏</el-button>
</template> </template>
@ -178,7 +194,6 @@
<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'
// 🔴 修改 1: 引入 request 替代 axios
import request from '../utils/request' 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, Avatar } from '@element-plus/icons-vue' import { Clock, DataLine, Document, Refresh, EditPen, Search, Edit, RefreshRight, Delete, RefreshLeft, SwitchButton, Avatar } from '@element-plus/icons-vue'
@ -195,10 +210,34 @@ 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 userRole = ref('') // 存储用户角色: 'admin' | 'engineer' | 'client'
// 计算属性:是否为超级管理员
const isAdmin = computed(() => userRole.value === 'admin')
// 计算属性:是否为工程师
const isEngineer = computed(() => userRole.value === 'engineer')
// 计算属性:是否为客户
const isClient = computed(() => userRole.value === 'client')
// 组合权限:是否有设备管理权限 (管理员 OR 工程师)
// 用于:切换维修模式、修改安装地点
const canManageDevice = computed(() => isAdmin.value || isEngineer.value)
// 角色显示名称
const roleDisplayName = computed(() => {
if (isAdmin.value) return '超级管理员'
if (isEngineer.value) return '设备工程师'
return '客户/浏览者'
})
// 角色Tag颜色
const roleTagType = computed(() => {
if (isAdmin.value) return 'danger'
if (isEngineer.value) return 'warning'
return 'info'
})
// --------------------
// 计算表格高度:手机端预留更多空间给折行的头部
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
@ -207,8 +246,6 @@ const tableHeight = computed(() => {
const filters = reactive({ status: 'all', keyword: '' }) const filters = reactive({ status: 'all', keyword: '' })
// 🔴 修改 2: 删除了 API_BASE因为 request.js 已经配置了 baseURL
const dataMonitorRef = ref(null) const dataMonitorRef = ref(null)
const maintenanceLogsRef = ref(null) const maintenanceLogsRef = ref(null)
@ -227,10 +264,7 @@ const goToUserManagement = () => {
const handleLogout = () => { const handleLogout = () => {
ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => { ElMessageBox.confirm('确定退出系统吗?', '提示', { type: 'warning' }).then(() => {
localStorage.removeItem('isLoggedIn') localStorage.clear() // 清除所有缓存
localStorage.removeItem('token')
localStorage.removeItem('role')
localStorage.removeItem('user_id')
router.push('/') router.push('/')
ElMessage.success('已安全退出') ElMessage.success('已安全退出')
}).catch(() => {}) }).catch(() => {})
@ -239,8 +273,6 @@ const handleLogout = () => {
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
// 🔴 修改 3: 直接使用 request.get无需手动获取 token 和配置 headers
// request.js 的拦截器会自动完成这一切
const res = await request.get('/api/devices_overview') const res = await request.get('/api/devices_overview')
const backendList = res.data.data || res.data const backendList = res.data.data || res.data
@ -288,7 +320,6 @@ const fetchData = async () => {
rawData.value = processedData rawData.value = processedData
lastCheckTime.value = new Date().toLocaleString() lastCheckTime.value = new Date().toLocaleString()
} catch (e) { } catch (e) {
// request.js 会处理 401/422这里主要处理网络错误
console.error(e) console.error(e)
} finally { } finally {
loading.value = false loading.value = false
@ -296,12 +327,20 @@ 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)
}
}
// 🔐 只有 Admin 能调用的手动检测
const runManualMonitor = async () => { const runManualMonitor = async () => {
if (!isAdmin.value) return ElMessage.warning('权限不足')
runningTask.value = true runningTask.value = true
try { try {
// 🔴 修改 4: 使用 request
const res = await request.post('/api/run_monitor') const res = await request.post('/api/run_monitor')
ElMessage.success(res.data.message || '任务启动') ElMessage.success(res.data.message || '任务启动')
setTimeout(() => fetchData(), 3000) setTimeout(() => fetchData(), 3000)
@ -322,6 +361,12 @@ const filteredData = computed(() => {
}) })
const handleEditSite = (row) => { const handleEditSite = (row) => {
// 🔐 权限校验
if (!canManageDevice.value) {
ElMessage.info('您没有修改权限')
return
}
row.tempSite = row.install_site; row.isEditingSite = true row.tempSite = row.install_site; row.isEditingSite = true
nextTick(() => { nextTick(() => {
const inputs = document.querySelectorAll('.site-input-inner input') const inputs = document.querySelectorAll('.site-input-inner input')
@ -334,16 +379,17 @@ 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 {
// 🔴 修改 5: 使用 request
await request.post('/api/update_site', { name: row.name, site: row.tempSite }) 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('更新失败') }
} }
const handleMaintenanceBeforeChange = (row) => { const handleMaintenanceBeforeChange = (row) => {
// 🔐 权限校验已经在 v-if 做过,这里是双重保险
if (!canManageDevice.value) return Promise.reject()
return new Promise((resolve) => { return new Promise((resolve) => {
const newVal = !row.is_maintaining const newVal = !row.is_maintaining
// 🔴 修改 6: 使用 request
request.post('/api/toggle_maintenance', { name: row.name, is_maintaining: newVal }) 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) })
@ -351,8 +397,8 @@ const handleMaintenanceBeforeChange = (row) => {
} }
const toggleHidden = async (row, targetState) => { const toggleHidden = async (row, targetState) => {
if (!isAdmin.value) return // 🔐 双重保险
try { try {
// 🔴 修改 7: 使用 request
await request.post('/api/toggle_hidden', { name: row.name, is_hidden: targetState }) 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('操作失败') }
@ -373,7 +419,12 @@ const updateDimensions = () => {
} }
onMounted(() => { onMounted(() => {
// 从本地存储获取角色,默认为 client
userRole.value = localStorage.getItem('role') || 'client' userRole.value = localStorage.getItem('role') || 'client'
// 安全日志
console.log('Current User Role:', userRole.value)
fetchData() fetchData()
window.addEventListener('resize', updateDimensions) window.addEventListener('resize', updateDimensions)
}) })
@ -423,7 +474,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', updateDimensions))
.success-text { color: #67C23A; } .success-text { color: #67C23A; }
.maintenance-text { color: #409EFF; } .maintenance-text { color: #409EFF; }
.display-cell { cursor: pointer; padding: 4px 0; display: flex; align-items: center; justify-content: space-between; } .display-cell { 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; }

View File

@ -47,7 +47,14 @@
<el-empty <el-empty
v-if="!loading && chartModules.length === 0" v-if="!loading && chartModules.length === 0"
:description="emptyText" :description="emptyText"
/> >
<template #default>
<div>{{ emptyText }}</div>
<div style="font-size: 12px; color: #999; margin-top: 5px;" v-if="emptyText.includes('解析失败')">
(请按 F12 查看控制台 Console 日志以排查数据格式)
</div>
</template>
</el-empty>
<div v-else class="charts-scroll-container"> <div v-else class="charts-scroll-container">
<div <div
@ -65,11 +72,9 @@
<script setup> <script setup>
import { ref, nextTick, onBeforeUnmount } from 'vue' import { ref, nextTick, onBeforeUnmount } from 'vue'
// 🔴 修改 1: 引入 request 替代 axios import request from '../utils/request' // 确保 request 工具路径正确
import request from '../utils/request'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { ElMessage, ElConfigProvider } from 'element-plus' import { ElConfigProvider } from 'element-plus'
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'
// --- 状态定义 --- // --- 状态定义 ---
@ -82,19 +87,24 @@ const dataTimestamp = ref('')
const chartModules = ref([]) const chartModules = ref([])
const emptyText = ref('暂无数据') const emptyText = ref('暂无数据')
// 🔴 修改 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 }
// 动态 Ref 设置函数
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, ' ') : '')
// 获取今天的日期字符串 YYYY-MM-DD
const getTodayString = () => { const getTodayString = () => {
const today = new Date() const today = new Date()
const y = today.getFullYear() const y = today.getFullYear()
@ -103,13 +113,14 @@ const getTodayString = () => {
return `${y}-${m}-${d}` return `${y}-${m}-${d}`
} }
// --- 核心入口:供父组件调用 --- // --- 核心入口 ---
const open = (row) => { const open = (row) => {
visible.value = true visible.value = true
deviceName.value = row.name deviceName.value = row.name
currentSource.value = row.source currentSource.value = row.source
chartModules.value = [] chartModules.value = []
// 处理初始日期
if (row.latest_time && row.latest_time !== 'N/A') { if (row.latest_time && row.latest_time !== 'N/A') {
dataTimestamp.value = row.latest_time dataTimestamp.value = row.latest_time
try { try {
@ -127,22 +138,23 @@ const open = (row) => {
loadData() loadData()
} }
// 日期改变回调
const handleDateChange = () => { const handleDateChange = () => {
dataTimestamp.value = '' dataTimestamp.value = ''
loadData() loadData()
} }
// --- 数据加载逻辑 --- // --- 数据加载逻辑 (已修复崩溃问题) ---
const loadData = async () => { const loadData = async () => {
if (!deviceName.value || !selectedDate.value) return if (!deviceName.value || !selectedDate.value) return
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 request.get('/api/device_data_by_date', { const res = await request.get('/api/device_data_by_date', {
params: { params: {
name: deviceName.value, name: deviceName.value,
@ -150,22 +162,38 @@ const loadData = async () => {
} }
}) })
const { content, source } = res.data // 1. 获取原始数据
const rawContent = res.data.content
const source = res.data.source
// 2. 关键修复:安全转换。如果 content 是 null/undefined转为空字符串如果是对象转为字符串。
let safeContent = ''
if (rawContent !== null && rawContent !== undefined) {
safeContent = typeof rawContent === 'object' ? JSON.stringify(rawContent) : String(rawContent)
}
const effectiveSource = source || currentSource.value const effectiveSource = source || currentSource.value
if (!content || content === '{}' || content === 'null') { // Debug日志
console.log(`[LoadData] Source: ${effectiveSource}, Safe Content Length: ${safeContent.length}`)
// 3. 判空逻辑
// 注意:有时候后端返回字符串 "null" 或 "{}" 也代表空
if (!safeContent || safeContent === 'null' || safeContent === '{}' || safeContent === '""') {
emptyText.value = `${selectedDate.value} 无数据记录` emptyText.value = `${selectedDate.value} 无数据记录`
} else { } else {
// 4. 解析数据
const modules = parseChartData({ const modules = parseChartData({
name: deviceName.value, name: deviceName.value,
content, content: safeContent, // 传入处理后的安全字符串
source: effectiveSource source: effectiveSource
}) })
chartModules.value = modules chartModules.value = modules
if (modules.length === 0) { if (modules.length === 0) {
// 安全截取字符串,避免报错
console.warn('解析结果为空。原始内容片段:', safeContent.substring(0, 100))
emptyText.value = '数据解析失败 (格式不匹配)' emptyText.value = '数据解析失败 (格式不匹配)'
} else { } else {
await nextTick() await nextTick()
@ -177,30 +205,41 @@ 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)
// request.js 会处理通用错误,这里可以额外提示业务层面的失败 emptyText.value = '数据加载异常'
} }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// --- 数据解析逻辑 (保持不变) --- // --- 解析器106 系列 ---
function parse106Data(content) { function parse106Data(content) {
if (typeof content !== 'string') return [] if (typeof content !== 'string') return []
const modules = [] const modules = []
const infoRegex = /FS\d_Info,Model,([^,]+),SN,([^,]+).*?Wavelength,([\d\.,\s]+)/gs
let match
while ((match = infoRegex.exec(content)) !== null) { // 宽松正则:不强制开头,允许空格,允许跨行匹配
const model = match[1] const blockRegex = /(?:FS\d_Info|Info)?[\s\S]*?Model\s*,\s*([^,\r\n]+)[\s\S]*?SN\s*,\s*([^,\r\n]+)[\s\S]*?Wavelength\s*,\s*([0-9\.,\s]+)/gi
const sn = match[2]
const wavelengths = match[3].split(',').map(Number).filter((n) => !isNaN(n)) let match
blockRegex.lastIndex = 0
while ((match = blockRegex.exec(content)) !== null) {
const modelRaw = match[1].trim()
const snRaw = match[2].trim()
const waveRaw = match[3]
const wavelengths = waveRaw.split(',').map(Number).filter((n) => !isNaN(n))
if (wavelengths.length === 0) continue
const series = [] const series = []
for (let p = 1; p <= 4; p++) { for (let p = 1; p <= 4; p++) {
const dMatch = content.match( // 转义 Model 名称中的特殊字符
new RegExp(`${model.trim()}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i') const escapedModel = modelRaw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
)
const pRegex = new RegExp(`${escapedModel}_P${p}[^0-9-]*([\\d\\.,\\s-]+)`, 'i')
const dMatch = content.match(pRegex)
if (dMatch) { if (dMatch) {
const vals = dMatch[1].split(',').map((v) => parseFloat(v)) const vals = dMatch[1].split(',').map((v) => parseFloat(v))
if (vals.some((v) => v !== null && !isNaN(v))) { if (vals.some((v) => v !== null && !isNaN(v))) {
@ -212,16 +251,19 @@ function parse106Data(content) {
} }
} }
} }
if (series.length) { if (series.length) {
modules.push({ type: '106', model, sn, xAxis: wavelengths, series }) modules.push({ type: '106', model: modelRaw, sn: snRaw, xAxis: wavelengths, series })
} }
} }
return modules return modules
} }
// --- 解析器82 系列 ---
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
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 [{
@ -241,19 +283,25 @@ function parse82Data(content, deviceName) {
} }
} }
// --- 主解析入口 ---
function parseChartData(device) { function parseChartData(device) {
if (!device || !device.content) return [] if (!device || !device.content) return []
const is106Site = device.source && device.source.includes('106')
if (is106Site) { // 这里的 content 已经是经过 loadData 转换的安全字符串了
return parse106Data(device.content) const contentStr = device.content.trim()
const is106Source = (device.source && device.source.includes('106'))
// 判断是否像 106 文本 (不以{开头 且 包含Model关键字)
const looksLike106Text = !contentStr.startsWith('{') && /Model/i.test(contentStr)
if (is106Source || looksLike106Text) {
return parse106Data(contentStr)
} else { } else {
return parse82Data(device.content, device.name) return parse82Data(contentStr, device.name)
} }
} }
// --- ECharts 渲染逻辑 (保持不变) --- // --- ECharts 配置 ---
function getChartOption(moduleData, isMobile = false) { function getChartOption(moduleData, isMobile = false) {
const titleText = moduleData.type === '106' const titleText = moduleData.type === '106'
? `Model: ${moduleData.model} (SN: ${moduleData.sn})` ? `Model: ${moduleData.model} (SN: ${moduleData.sn})`
@ -266,26 +314,60 @@ function getChartOption(moduleData, isMobile = false) {
top: 10, top: 10,
textStyle: { fontSize: isMobile ? 14 : 16 }, textStyle: { fontSize: isMobile ? 14 : 16 },
}, },
tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'cross' } }, tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'cross' }
},
legend: { top: 35, type: 'scroll' }, legend: { top: 35, type: 'scroll' },
toolbox: { feature: { saveAsImage: { title: '保存' }, dataZoom: { title: { zoom: '缩放', back: '还原' } } } }, toolbox: {
grid: { top: 80, bottom: 30, right: isMobile ? 10 : 40, left: isMobile ? 40 : 50 }, feature: {
xAxis: { type: 'category', data: moduleData.xAxis, boundaryGap: false, name: 'nm' }, saveAsImage: { title: '保存' },
yAxis: { type: 'value', min: 'dataMin', max: 'dataMax', scale: true }, dataZoom: { title: { zoom: '缩放', back: '还原' } }
}
},
grid: {
top: 80,
bottom: 30,
right: isMobile ? 10 : 40,
left: isMobile ? 40 : 50
},
xAxis: {
type: 'category',
data: moduleData.xAxis,
boundaryGap: false,
name: 'nm'
},
yAxis: {
type: 'value',
min: 'dataMin',
max: 'dataMax',
scale: true
},
series: moduleData.series.map((s) => ({ series: moduleData.series.map((s) => ({
name: s.name, type: 'line', data: s.data, connectNulls: false, smooth: true, showSymbol: false, name: s.name,
lineStyle: { width: 1.5, color: s.color }, areaStyle: { opacity: 0.1, color: s.color }, type: 'line',
data: s.data,
connectNulls: false,
smooth: true,
showSymbol: false,
lineStyle: { width: 1.5, color: s.color },
areaStyle: { opacity: 0.1, color: s.color },
})), })),
} }
} }
// --- ECharts 初始化 ---
const initCharts = () => { const initCharts = () => {
if (chartModules.value.length === 0) return if (chartModules.value.length === 0) return
const isMobile = window.innerWidth < 768 const isMobile = window.innerWidth < 768
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() const oldInstance = echarts.getInstanceByDom(el)
if (oldInstance) oldInstance.dispose()
const chart = echarts.init(el) const chart = echarts.init(el)
chart.setOption(getChartOption(mod, isMobile)) chart.setOption(getChartOption(mod, isMobile))
chartInstances.push(chart) chartInstances.push(chart)
@ -293,6 +375,7 @@ const initCharts = () => {
}) })
} }
// --- ECharts 销毁 ---
const disposeCharts = () => { const disposeCharts = () => {
chartInstances.forEach((chart) => chart && chart.dispose()) chartInstances.forEach((chart) => chart && chart.dispose())
chartInstances = [] chartInstances = []

View File

@ -23,20 +23,23 @@
@change="fetchLogs" @change="fetchLogs"
style="width: 260px" style="width: 260px"
/> />
<el-input <el-input
v-model="keyword" v-model="keyword"
placeholder="搜索:设备名 / 工程师 / 内容" placeholder="搜索:设备名 / 工程师 / 内容"
style="width: 300px" style="width: 300px"
clearable :disabled="isSearchLocked"
:clearable="!isSearchLocked"
@clear="fetchLogs" @clear="fetchLogs"
@keyup.enter="fetchLogs" @keyup.enter="fetchLogs"
> >
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-button type="primary" @click="fetchLogs">查询</el-button>
<el-button type="primary" @click="fetchLogs" :disabled="isSearchLocked">查询</el-button>
</div> </div>
<div class="action-group"> <div class="action-group" v-if="userRole !== 'client'">
<el-button type="success" icon="Plus" @click="openAddDialog">新增记录</el-button> <el-button type="success" icon="Plus" @click="openAddDialog">新增记录</el-button>
</div> </div>
</div> </div>
@ -58,10 +61,16 @@
</el-table-column> </el-table-column>
<el-table-column prop="location" label="地点" width="150" show-overflow-tooltip /> <el-table-column prop="location" label="地点" width="150" show-overflow-tooltip />
<el-table-column prop="engineer" label="工程师" width="120" />
<el-table-column prop="engineer" label="工程师" width="120">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.engineer }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="维修/故障详情" min-width="300" show-overflow-tooltip /> <el-table-column prop="content" label="维修/故障详情" min-width="300" show-overflow-tooltip />
<el-table-column label="操作" width="180" align="center" fixed="right"> <el-table-column label="操作" width="180" align="center" fixed="right" v-if="userRole !== 'client'">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
type="primary" type="primary"
@ -73,7 +82,11 @@
修改 修改
</el-button> </el-button>
<el-popconfirm title="确定删除这条记录吗?" @confirm="deleteLog(row.id)"> <el-popconfirm
v-if="userRole === 'admin'"
title="确定删除这条记录吗?"
@confirm="deleteLog(row.id)"
>
<template #reference> <template #reference>
<el-button type="danger" link icon="Delete">删除</el-button> <el-button type="danger" link icon="Delete">删除</el-button>
</template> </template>
@ -99,13 +112,21 @@
:disabled="logDialog.isEdit" :disabled="logDialog.isEdit"
/> />
<div v-if="logDialog.isEdit" class="form-tip"> <div v-if="logDialog.isEdit" class="form-tip">
<el-icon><InfoFilled /></el-icon> 为了数据追溯修改模式下禁止更改关联设备 <el-icon><InfoFilled /></el-icon> 关联设备不可变更
</div> </div>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="工程师"> <el-form-item label="工程师">
<el-input v-model="logDialog.form.engineer" placeholder="例: 张工" /> <el-input
v-model="logDialog.form.engineer"
:placeholder="userRole === 'engineer' ? '' : '请输入工程师姓名'"
:disabled="userRole === 'engineer'"
>
<template #prefix>
<el-icon><UserFilled /></el-icon>
</template>
</el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -136,23 +157,24 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive, onMounted } from 'vue'
// 🔴 修改 1: 引入 request
import request from '../utils/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, UserFilled } 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'
// 🔴 修改 2: 删除 API_BASE // --- 状态定义 ---
// --- 核心状态 ---
const visible = ref(false) const visible = ref(false)
const loading = ref(false) const loading = ref(false)
const logsList = ref([]) const logsList = ref([])
const keyword = ref('') const keyword = ref('')
const dateRange = ref([]) const dateRange = ref([])
const isSearchLocked = ref(false)
// 用户信息
const userRole = ref('')
const currentUsername = ref('')
// 弹窗状态封装
const logDialog = reactive({ const logDialog = reactive({
visible: false, visible: false,
submitting: false, submitting: false,
@ -166,17 +188,36 @@ const logDialog = reactive({
} }
}) })
// --- 方法逻辑 --- // 刷新用户信息,确保从本地存储获取最新数据
const refreshUserInfo = () => {
userRole.value = localStorage.getItem('role') || 'client'
currentUsername.value = localStorage.getItem('username') || ''
}
onMounted(() => {
refreshUserInfo()
})
// --- 方法 ---
// 1. 打开主弹窗
const open = (prefillData = null) => { const open = (prefillData = null) => {
refreshUserInfo()
visible.value = true visible.value = true
isSearchLocked.value = false
keyword.value = ''
if (prefillData && prefillData.deviceName) { if (prefillData && prefillData.deviceName) {
keyword.value = prefillData.deviceName keyword.value = prefillData.deviceName
// 非 Admin 从特定设备点进来时锁定搜索
if (userRole.value !== 'admin') {
isSearchLocked.value = true
}
} }
fetchLogs() fetchLogs()
} }
// 2. 获取数据列表 // 2. 获取列表
const fetchLogs = async () => { const fetchLogs = async () => {
loading.value = true loading.value = true
try { try {
@ -185,7 +226,6 @@ 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]
} }
// 🔴 修改 3: 使用 request.get
const res = await request.get('/api/logs/list', { params }) const res = await request.get('/api/logs/list', { params })
logsList.value = res.data.data logsList.value = res.data.data
} catch (e) { } catch (e) {
@ -195,63 +235,70 @@ const fetchLogs = async () => {
} }
} }
// 3. 处理新增 // 3. 打开新增对话框
const openAddDialog = () => { const openAddDialog = () => {
refreshUserInfo()
logDialog.isEdit = false logDialog.isEdit = false
logDialog.form = { logDialog.form = {
id: null, id: null,
device_name: keyword.value || '', device_name: keyword.value || '', // 如果主表单有搜索词,自动填入设备名
engineer: '', // 🟢 修复:如果是 Engineer强制填入用户名否则才允许手动输入
engineer: userRole.value === 'engineer' ? currentUsername.value : '',
location: '', location: '',
content: '' content: ''
} }
logDialog.visible = true logDialog.visible = true
} }
// 4. 处理修改 // 4. 打开编辑对话框
const openEditDialog = (row) => { const openEditDialog = (row) => {
refreshUserInfo()
logDialog.isEdit = true logDialog.isEdit = true
logDialog.form = { logDialog.form = {
id: row.id, id: row.id,
device_name: row.device_name, device_name: row.device_name,
engineer: row.engineer, // 🟢 修复:工程师修改时,也强制纠正为当前用户姓名,防止冒名顶替
engineer: userRole.value === 'engineer' ? currentUsername.value : row.engineer,
location: row.location, location: row.location,
content: row.content content: row.content
} }
logDialog.visible = true logDialog.visible = true
} }
// 5. 提交表单 // 5. 提交 (新增/修改)
const submitLog = async () => { const submitLog = async () => {
if (!logDialog.form.device_name || !logDialog.form.content) { // 🟢 最终拦截:确保如果是工程师,提交上去的姓名一定是当前的正确姓名
return ElMessage.warning('设备名称和事件内容为必填项') if (userRole.value === 'engineer') {
logDialog.form.engineer = currentUsername.value
}
if (!logDialog.form.device_name || !logDialog.form.content || !logDialog.form.engineer) {
return ElMessage.warning('信息填写不完整(设备名、工程师、内容为必填)')
} }
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'
// 🔴 修改 4: 使用 request.post
await request.post(endpoint, logDialog.form) 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(e.response?.data?.msg || '操作失败')
} finally { } finally {
logDialog.submitting = false logDialog.submitting = false
} }
} }
// 6. 删除逻辑 // 6. 删除 (仅 Admin)
const deleteLog = async (id) => { const deleteLog = async (id) => {
try { try {
// 🔴 修改 5: 使用 request.post
await request.post('/api/logs/delete', { id }) await request.post('/api/logs/delete', { id })
ElMessage.success('记录已安全删除') ElMessage.success('记录已安全删除')
fetchLogs() fetchLogs()
} catch (e) { } catch (e) {
ElMessage.error('删除操作失败') ElMessage.error('删除失败,权限不足')
} }
} }
@ -289,13 +336,15 @@ defineExpose({ open })
gap: 4px; gap: 4px;
} }
:deep(.el-input.is-disabled .el-input__wrapper) { /* 优化禁用输入框的显示,让文字更清晰,颜色更深,像正常文本一样 */
background-color: #f5f7fa; :deep(.el-input.is-disabled .el-input__inner) {
box-shadow: 0 0 0 1px #e4e7ed inset; color: #303133 !important;
-webkit-text-fill-color: #303133 !important;
font-weight: bold;
} }
:deep(.el-input.is-disabled .el-input__inner) { /* 即使禁用,图标也保持可见 */
color: #606266; :deep(.el-input.is-disabled .el-input__prefix) {
-webkit-text-fill-color: #606266; color: #409eff;
} }
</style> </style>

View File

@ -4,32 +4,65 @@
<template #header> <template #header>
<div class="header-row"> <div class="header-row">
<div class="left-panel"> <div class="left-panel">
<h2 class="sys-title">👤 客户权限管理</h2> <h2 class="sys-title">👥 用户与权限管理</h2>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<el-button @click="router.push('/dashboard')" icon="Back">返回监控</el-button> <el-button @click="router.push('/dashboard')" icon="Back">返回监控</el-button>
<el-button type="primary" icon="Plus" @click="showCreateModal = true">新建</el-button> <el-button type="primary" icon="Plus" @click="openCreateModal">新建</el-button>
</div> </div>
</div> </div>
</template> </template>
<el-table :data="users" border style="width: 100%" v-loading="loading"> <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="id" label="ID" width="80" align="center" />
<el-table-column prop="username" label="客户名称" min-width="150" />
<el-table-column prop="username" label="用户名" min-width="150">
<template #default="{ row }">
<span style="font-weight: bold;">{{ row.username }}</span>
</template>
</el-table-column>
<el-table-column prop="role" label="角色身份" width="150" align="center">
<template #default="{ row }">
<el-tag v-if="row.role === 'admin'" type="danger" effect="dark">超级管理员</el-tag>
<el-tag v-else-if="row.role === 'engineer'" type="warning" effect="dark">设备工程师</el-tag>
<el-tag v-else type="info" effect="plain">普通客户</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="180"> <el-table-column prop="created_at" label="创建时间" min-width="180">
<template #default="{ row }"> <template #default="{ row }">
{{ new Date(row.created_at).toLocaleString() }} {{ new Date(row.created_at).toLocaleString() }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="可见设备" min-width="120">
<el-table-column label="关联设备数" min-width="120" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag>{{ row.allowed_device_ids?.length || 0 }} </el-tag> <el-tag v-if="row.role === 'admin'" type="danger" effect="plain">全部权限</el-tag>
<el-tag v-else effect="plain" type="success">{{ row.allowed_device_ids?.length || 0 }} </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link icon="Setting" @click="openPermissionModal(row)">分配权限</el-button> <el-button
<el-popconfirm title="确定删除该客户吗?" @confirm="deleteUser(row.id)"> v-if="row.role !== 'admin'"
type="primary"
link
icon="Setting"
@click="openPermissionModal(row)"
>
分配设备
</el-button>
<el-popconfirm
title="确定删除该用户吗? 此操作不可恢复。"
confirm-button-text="删除"
cancel-button-text="取消"
icon="Warning"
icon-color="red"
@confirm="deleteUser(row.id)"
>
<template #reference> <template #reference>
<el-button type="danger" link icon="Delete">删除</el-button> <el-button type="danger" link icon="Delete">删除</el-button>
</template> </template>
@ -39,24 +72,36 @@
</el-table> </el-table>
</el-card> </el-card>
<el-dialog v-model="showCreateModal" title="新建客户账号" width="400px"> <el-dialog v-model="showCreateModal" title="新建账号" width="400px">
<el-form :model="newUser" label-width="80px"> <el-form :model="newUser" label-width="80px">
<el-form-item label="用户名"> <el-form-item label="用户名">
<el-input v-model="newUser.username" placeholder="请输入客户登录名" /> <el-input v-model="newUser.username" placeholder="请输入登录名" />
</el-form-item> </el-form-item>
<el-form-item label="密码"> <el-form-item label="密码">
<el-input v-model="newUser.password" type="password" placeholder="设置初始密码" show-password /> <el-input v-model="newUser.password" type="password" placeholder="设置初始密码" show-password />
</el-form-item> </el-form-item>
<el-form-item label="角色权限">
<el-select v-model="newUser.role" placeholder="请选择角色" style="width: 100%">
<el-option label="普通客户 (只读)" value="client" />
<el-option label="设备工程师 (可维护)" value="engineer" />
<el-option label="超级管理员 (Root权限)" value="admin" />
</el-select>
<div style="font-size: 12px; color: #999; margin-top: 5px; line-height: 1.2;">
<span v-if="newUser.role === 'admin'" style="color: #f56c6c;">* 拥有删除用户爬虫控制等最高权限</span>
<span v-else-if="newUser.role === 'engineer'" style="color: #e6a23c;">* 拥有修改设备地点写日志权限</span>
<span v-else>* 仅可查看被分配的设备数据</span>
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="showCreateModal = false">取消</el-button> <el-button @click="showCreateModal = false">取消</el-button>
<el-button type="primary" @click="createClient">确认创建</el-button> <el-button type="primary" @click="createUser" :loading="creating">确认创建</el-button>
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="showPermissionModal" :title="`给 ${currentUser?.username} 分配设备`" width="600px"> <el-dialog v-model="showPermissionModal" :title="`给 [${currentUser?.username}] 分配设备`" width="650px">
<div class="permission-transfer"> <div class="permission-transfer">
<el-transfer <el-transfer
v-model="selectedDeviceIds" v-model="selectedDeviceIds"
@ -64,7 +109,7 @@
:titles="['可选设备', '已授权设备']" :titles="['可选设备', '已授权设备']"
:props="{ key: 'id', label: 'label' }" :props="{ key: 'id', label: 'label' }"
filterable filterable
filter-placeholder="搜索设备" filter-placeholder="搜索设备名称"
/> />
</div> </div>
<template #footer> <template #footer>
@ -80,21 +125,21 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
// 🔴 修改 1: 引入 request
import request from '../utils/request' import request from '../utils/request'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Back, Plus, Setting, Delete } from '@element-plus/icons-vue' import { Back, Plus, Setting, Delete, Warning } from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
// 🔴 修改 2: 删除 API_BASE
const loading = ref(false) const loading = ref(false)
const creating = ref(false)
const users = ref([]) const users = ref([])
const rawDevices = ref([]) // 原始设备列表 const rawDevices = ref([])
const showCreateModal = ref(false) const showCreateModal = ref(false)
const showPermissionModal = ref(false) const showPermissionModal = ref(false)
const newUser = ref({ username: '', password: '' }) // 默认新建角色为 client
const newUser = ref({ username: '', password: '', role: 'client' })
const currentUser = ref(null) const currentUser = ref(null)
const selectedDeviceIds = ref([]) const selectedDeviceIds = ref([])
@ -102,7 +147,7 @@ const selectedDeviceIds = ref([])
const allDevices = computed(() => { const allDevices = computed(() => {
return rawDevices.value.map(d => ({ return rawDevices.value.map(d => ({
id: d.id, id: d.id,
label: `${d.name} (${d.install_site || '未命名'})` label: `${d.name} ${d.install_site ? '(' + d.install_site + ')' : ''}`
})) }))
}) })
@ -114,11 +159,9 @@ onMounted(async () => {
const fetchUsers = async () => { const fetchUsers = async () => {
loading.value = true loading.value = true
try { try {
// 🔴 修改 3: 使用 request.get移除 headers
const res = await request.get('/api/admin/users') const res = await request.get('/api/admin/users')
users.value = res.data users.value = res.data.data || res.data
} catch (e) { } catch (e) {
// 拦截器已处理 401/403这里只处理通用错误提示
console.error(e) console.error(e)
} finally { } finally {
loading.value = false loading.value = false
@ -127,66 +170,112 @@ const fetchUsers = async () => {
const fetchAllDevices = async () => { const fetchAllDevices = async () => {
try { try {
// 🔴 修改 4: 使用 request.get复用 dashboard 接口
const res = await request.get('/api/devices_overview') const res = await request.get('/api/devices_overview')
const list = res.data.data || res.data rawDevices.value = res.data.data || res.data
rawDevices.value = list
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
} }
const createClient = async () => { const openCreateModal = () => {
// 每次打开重置表单
newUser.value = {username: '', password: '', role: 'client'}
showCreateModal.value = true
}
const createUser = async () => {
if (!newUser.value.username || !newUser.value.password) return ElMessage.warning('请填写完整') if (!newUser.value.username || !newUser.value.password) return ElMessage.warning('请填写完整')
creating.value = true
try { try {
// 🔴 修改 5: 使用 request.post // 发送 role 到后端,数据库直接存入
await request.post('/api/admin/create_client', newUser.value) await request.post('/api/admin/create_user', newUser.value)
ElMessage.success('创建成功') ElMessage.success('用户创建成功')
showCreateModal.value = false showCreateModal.value = false
newUser.value = { username: '', password: '' }
fetchUsers() fetchUsers()
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.msg || '创建失败') ElMessage.error(e.response?.data?.msg || '创建失败')
} finally {
creating.value = false
} }
} }
const openPermissionModal = (user) => { const openPermissionModal = (user) => {
currentUser.value = user currentUser.value = user
// 回显已选权限
selectedDeviceIds.value = user.allowed_device_ids || [] selectedDeviceIds.value = user.allowed_device_ids || []
showPermissionModal.value = true showPermissionModal.value = true
} }
const savePermissions = async () => { const savePermissions = async () => {
try { try {
// 🔴 修改 6: 使用 request.post
await request.post('/api/admin/assign_devices', { await request.post('/api/admin/assign_devices', {
user_id: currentUser.value.id, user_id: currentUser.value.id,
device_ids: selectedDeviceIds.value device_ids: selectedDeviceIds.value
}) })
ElMessage.success('权限已更新') ElMessage.success('权限已更新')
showPermissionModal.value = false showPermissionModal.value = false
fetchUsers() // 刷新列表查看数量变化 fetchUsers()
} catch (e) { } catch (e) {
ElMessage.error('保存失败') ElMessage.error('保存失败')
} }
} }
const deleteUser = async (id) => { const deleteUser = async (id) => {
ElMessage.info('删除功能暂需后端接口支持') try {
await request.post('/api/admin/delete_user', {user_id: id})
ElMessage.success('用户已删除')
fetchUsers()
} catch (e) {
ElMessage.error(e.response?.data?.msg || '删除失败')
}
} }
</script> </script>
<style scoped> <style scoped>
.user-manage-container { padding: 10px; background: #f5f7fa; min-height: 100vh; box-sizing: border-box; } .user-manage-container {
.main-card { border-radius: 8px; min-height: 80vh; } padding: 10px;
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } background: #f5f7fa;
.sys-title { margin: 0; font-size: 20px; color: #303133; font-weight: 700; } min-height: 100vh;
.header-actions { display: flex; gap: 10px; } 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: 250px;
}
:deep(.el-transfer-panel) { width: 220px; }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
:deep(.el-transfer-panel) { width: 100%; margin-bottom: 10px; } :deep(.el-transfer-panel) {
.permission-transfer { display: flex; flex-direction: column; } width: 100%;
margin-bottom: 10px;
}
.permission-transfer {
display: flex;
flex-direction: column;
}
} }
</style> </style>