Compare commits
10 Commits
7d683f3e65
...
466e94c4dd
| Author | SHA1 | Date | |
|---|---|---|---|
| 466e94c4dd | |||
| 59a6a10803 | |||
| f8f5b05d7d | |||
| a849e14b2c | |||
| 7e72c12f30 | |||
| decb7f5e1f | |||
| 1c8def7e6f | |||
| 9a0982e76d | |||
| 381d1fa675 | |||
| becd3cb010 |
@ -218,8 +218,8 @@ def delete_bom(bom_no):
|
|||||||
if not exist:
|
if not exist:
|
||||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||||
|
|
||||||
# 删除
|
# 删除(改为对象级删除以触发审计事件)
|
||||||
query.delete()
|
db.session.delete(exist)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
|
|||||||
@ -444,7 +444,11 @@ def clear_draft():
|
|||||||
# 清除指定会话
|
# 清除指定会话
|
||||||
query = query.filter_by(session_id=session_id)
|
query = query.filter_by(session_id=session_id)
|
||||||
|
|
||||||
count = query.delete()
|
# 改为对象级删除以触发审计事件
|
||||||
|
records = query.all()
|
||||||
|
count = len(records)
|
||||||
|
for rec in records:
|
||||||
|
db.session.delete(rec)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
|
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
|
||||||
@ -461,8 +465,11 @@ def start_new_session():
|
|||||||
清空整张草稿表,返回新的 session_id
|
清空整张草稿表,返回新的 session_id
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 清空整张草稿表
|
# 清空整张草稿表(改为对象级删除以触发审计事件)
|
||||||
deleted_count = StocktakeDraft.query.delete()
|
all_records = StocktakeDraft.query.all()
|
||||||
|
deleted_count = len(all_records)
|
||||||
|
for rec in all_records:
|
||||||
|
db.session.delete(rec)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# 生成新的 session_id
|
# 生成新的 session_id
|
||||||
@ -1148,10 +1155,14 @@ def generate_missing_stocktake():
|
|||||||
|
|
||||||
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
|
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
|
||||||
# 特征:user_id == 'system' (表示由系统自动生成)
|
# 特征:user_id == 'system' (表示由系统自动生成)
|
||||||
deleted_count = StocktakeDraft.query.filter(
|
# 改为对象级删除以触发审计事件
|
||||||
|
system_records = StocktakeDraft.query.filter(
|
||||||
StocktakeDraft.session_id == session_id,
|
StocktakeDraft.session_id == session_id,
|
||||||
StocktakeDraft.user_id == 'system'
|
StocktakeDraft.user_id == 'system'
|
||||||
).delete()
|
).all()
|
||||||
|
deleted_count = len(system_records)
|
||||||
|
for rec in system_records:
|
||||||
|
db.session.delete(rec)
|
||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")
|
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")
|
||||||
|
|||||||
210
inventory-backend/app/core/audit_listener.py
Normal file
210
inventory-backend/app/core/audit_listener.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
# inventory-backend/app/core/audit_listener.py
|
||||||
|
"""
|
||||||
|
SQLAlchemy Event Listener 审计监听器(单体架构版)
|
||||||
|
监听器亲自完成入库,不依赖 g 对象,不依赖装饰器回调。
|
||||||
|
只要模型发生 INSERT/UPDATE/DELETE,监听器直接创建 AuditLog 并挂载到当前事务 session。
|
||||||
|
"""
|
||||||
|
from sqlalchemy import event, inspect
|
||||||
|
from flask import current_app, request, has_request_context
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
IGNORE_FIELDS = {
|
||||||
|
'updated_at', 'update_time', 'modified_time', 'last_modified',
|
||||||
|
'created_at', 'create_time', 'created_on',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_value(value):
|
||||||
|
"""序列化值确保 JSON 兼容"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
if isinstance(value, (bytes, bytearray)):
|
||||||
|
try:
|
||||||
|
return value.decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
return '[二进制数据]'
|
||||||
|
if hasattr(value, '__class__') and value.__class__.__name__ in ('InstanceState', 'LazyLoader'):
|
||||||
|
return str(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_audit_model(mapper):
|
||||||
|
"""判断模型是否需要审计"""
|
||||||
|
if hasattr(mapper.class_, 'audit_enabled') and mapper.class_.audit_enabled is False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
AUDIT_WHITELIST = {
|
||||||
|
'MaterialBase', 'MaterialWarningSetting',
|
||||||
|
'StockBuy', 'StockSemi', 'StockProduct', 'StockService',
|
||||||
|
'RepairRecord', 'TransOutbound', 'TransBorrow', 'TransReturn',
|
||||||
|
'BomTable', 'StockTake', 'StockAdjust',
|
||||||
|
'TransScrap', 'SysUser'
|
||||||
|
}
|
||||||
|
return mapper.class_.__name__ in AUDIT_WHITELIST
|
||||||
|
|
||||||
|
|
||||||
|
def _get_module_name(mapper):
|
||||||
|
"""根据模型类名推断所属模块"""
|
||||||
|
name = mapper.class_.__name__
|
||||||
|
if 'Stock' in name or 'Buy' in name:
|
||||||
|
return '入库管理'
|
||||||
|
if 'Outbound' in name or 'TransOut' in name:
|
||||||
|
return '出库管理'
|
||||||
|
if 'Borrow' in name or 'Return' in name:
|
||||||
|
return '借还管理'
|
||||||
|
if 'Bom' in name:
|
||||||
|
return 'BOM管理'
|
||||||
|
if 'StockTake' in name or 'Adjust' in name or 'Scrap' in name:
|
||||||
|
return '盘点管理'
|
||||||
|
if 'Repair' in name:
|
||||||
|
return '维修管理'
|
||||||
|
if 'SysUser' in name or 'SysMenu' in name or 'SysRole' in name:
|
||||||
|
return '系统管理'
|
||||||
|
if 'Material' in name:
|
||||||
|
return '基础数据'
|
||||||
|
return '未知模块'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_user_info():
|
||||||
|
"""从当前 HTTP 请求中尽力提取用户信息,获取不到拉倒"""
|
||||||
|
user_id, username, ip = None, 'system', ''
|
||||||
|
if has_request_context():
|
||||||
|
try:
|
||||||
|
from flask_jwt_extended import get_jwt_identity, get_jwt
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
claims = get_jwt()
|
||||||
|
username = claims.get('username', 'system')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ip = request.headers.get('X-Forwarded-For', '') or request.remote_addr or ''
|
||||||
|
if ip and ',' in ip:
|
||||||
|
ip = ip.split(',')[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return user_id, username, ip
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 核心:监听器内部直接创建并挂载日志
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _create_audit_log(session, mapper, target, action, details):
|
||||||
|
"""
|
||||||
|
监听器内部直接实例化 AuditLog 并加入当前事务 session。
|
||||||
|
由 SQLAlchemy 生命周期保证随主事务一同提交或回滚。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
|
||||||
|
user_id, username, ip = _get_request_user_info()
|
||||||
|
module = _get_module_name(mapper)
|
||||||
|
|
||||||
|
target_id = None
|
||||||
|
if hasattr(target, 'id'):
|
||||||
|
target_id = target.id
|
||||||
|
elif hasattr(target, 'stock_id'):
|
||||||
|
target_id = target.stock_id
|
||||||
|
elif hasattr(target, 'bom_no'):
|
||||||
|
target_id = target.bom_no
|
||||||
|
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
action=action,
|
||||||
|
module=module,
|
||||||
|
target_id=str(target_id) if target_id else '0',
|
||||||
|
details=details,
|
||||||
|
ip_address=ip
|
||||||
|
)
|
||||||
|
session.add(log)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Audit log auto-creation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def before_update_listener(mapper, connection, target):
|
||||||
|
"""UPDATE 事件:抓取字段变更明细"""
|
||||||
|
if not _is_audit_model(mapper): return
|
||||||
|
try:
|
||||||
|
state = inspect(target)
|
||||||
|
changes = {}
|
||||||
|
for attr in state.attrs:
|
||||||
|
if attr.key in IGNORE_FIELDS: continue
|
||||||
|
if attr.history.has_changes():
|
||||||
|
old_val = attr.history.deleted[0] if attr.history.deleted else None
|
||||||
|
new_val = attr.history.added[0] if attr.history.added else None
|
||||||
|
changes[attr.key] = {
|
||||||
|
'old': _serialize_value(old_val),
|
||||||
|
'new': _serialize_value(new_val)
|
||||||
|
}
|
||||||
|
if changes:
|
||||||
|
_create_audit_log(connection, mapper, target, 'update', {'changes': changes})
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Audit Update Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def before_delete_listener(mapper, connection, target):
|
||||||
|
"""DELETE 事件:抓取被删除对象的完整快照"""
|
||||||
|
if not _is_audit_model(mapper): return
|
||||||
|
try:
|
||||||
|
state = inspect(target)
|
||||||
|
snap = {}
|
||||||
|
for attr in state.attrs:
|
||||||
|
val = getattr(target, attr.key, None)
|
||||||
|
snap[attr.key] = _serialize_value(val)
|
||||||
|
_create_audit_log(connection, mapper, target, 'delete', {'deleted_snapshot': snap})
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Audit Delete Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def after_insert_listener(mapper, connection, target):
|
||||||
|
"""INSERT 事件:抓取新增对象的完整快照"""
|
||||||
|
if not _is_audit_model(mapper): return
|
||||||
|
try:
|
||||||
|
state = inspect(target)
|
||||||
|
snap = {}
|
||||||
|
for attr in state.attrs:
|
||||||
|
val = getattr(target, attr.key, None)
|
||||||
|
snap[attr.key] = _serialize_value(val)
|
||||||
|
_create_audit_log(connection, mapper, target, 'insert', {'created': snap})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 注册函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def register_audit_listeners(db):
|
||||||
|
"""向所有需要审计的模型注册事件监听器"""
|
||||||
|
from app.models import (
|
||||||
|
MaterialBase, MaterialWarningSetting,
|
||||||
|
StockBuy, StockSemi, StockProduct, StockService,
|
||||||
|
RepairRecord, TransOutbound, TransBorrow, TransReturn,
|
||||||
|
BomTable, StockTake, StockAdjust,
|
||||||
|
TransScrap, SysUser
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_models = [
|
||||||
|
MaterialBase, MaterialWarningSetting,
|
||||||
|
StockBuy, StockSemi, StockProduct, StockService,
|
||||||
|
RepairRecord, TransOutbound, TransBorrow, TransReturn,
|
||||||
|
BomTable, StockTake, StockAdjust,
|
||||||
|
TransScrap, SysUser
|
||||||
|
]
|
||||||
|
|
||||||
|
audit_models = [m for m in audit_models if m is not None]
|
||||||
|
count = 0
|
||||||
|
for model in audit_models:
|
||||||
|
try:
|
||||||
|
event.listen(model, 'before_update', before_update_listener, propagate=True)
|
||||||
|
event.listen(model, 'before_delete', before_delete_listener, propagate=True)
|
||||||
|
event.listen(model, 'after_insert', after_insert_listener, propagate=True)
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return count
|
||||||
@ -46,4 +46,14 @@ def init_extensions(app):
|
|||||||
redis_client.ping()
|
redis_client.ping()
|
||||||
app.logger.info("✅ Redis connected successfully")
|
app.logger.info("✅ Redis connected successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
|
app.logger.warning(f"⚠️ Redis connection failed: {e}, single-device login will be disabled")
|
||||||
|
|
||||||
|
# ★ 注册 SQLAlchemy 审计监听器
|
||||||
|
# 必须在 db.init_app 之后调用,确保所有模型已映射
|
||||||
|
try:
|
||||||
|
from app.core.audit_listener import register_audit_listeners
|
||||||
|
with app.app_context():
|
||||||
|
count = register_audit_listeners(db)
|
||||||
|
app.logger.info(f"✅ 审计监听器注册成功,共绑定 {count} 个模型")
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"⚠️ 审计监听器注册失败: {e}")
|
||||||
@ -189,8 +189,10 @@ class BomService:
|
|||||||
raise ValueError(f'保存失败!当前子件配置与已有版本 {ver} 完全一致,请勿重复保存')
|
raise ValueError(f'保存失败!当前子件配置与已有版本 {ver} 完全一致,请勿重复保存')
|
||||||
|
|
||||||
# ===== 执行保存 =====
|
# ===== 执行保存 =====
|
||||||
# 仅删除当前版本的旧记录
|
# 仅删除当前版本的旧记录(改为对象级删除以触发审计事件)
|
||||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||||
|
for rec in old_records:
|
||||||
|
db.session.delete(rec)
|
||||||
|
|
||||||
for child in children:
|
for child in children:
|
||||||
bom = BomTable(
|
bom = BomTable(
|
||||||
@ -260,7 +262,11 @@ class BomService:
|
|||||||
existing = BomTable.query.filter_by(parent_id=parent_id).first()
|
existing = BomTable.query.filter_by(parent_id=parent_id).first()
|
||||||
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
|
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
|
||||||
|
|
||||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
# 改为对象级删除以触发审计事件
|
||||||
|
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||||
|
for rec in old_records:
|
||||||
|
db.session.delete(rec)
|
||||||
|
|
||||||
for item in child_list:
|
for item in child_list:
|
||||||
bom = BomTable(
|
bom = BomTable(
|
||||||
bom_no=bom_no, version=version, parent_id=parent_id,
|
bom_no=bom_no, version=version, parent_id=parent_id,
|
||||||
|
|||||||
@ -109,8 +109,10 @@ class PermissionService:
|
|||||||
try:
|
try:
|
||||||
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
|
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
|
||||||
|
|
||||||
# 2. 删除该角色旧的所有权限
|
# 2. 删除该角色旧的所有权限(改为对象级删除以触发审计事件)
|
||||||
SysRolePermission.query.filter_by(role_code=role_code).delete()
|
old_perms = SysRolePermission.query.filter_by(role_code=role_code).all()
|
||||||
|
for p in old_perms:
|
||||||
|
db.session.delete(p)
|
||||||
|
|
||||||
# 3. 准备新数据
|
# 3. 准备新数据
|
||||||
if permissions:
|
if permissions:
|
||||||
@ -374,10 +376,14 @@ class PermissionService:
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
for menu in legacy_menus:
|
for menu in legacy_menus:
|
||||||
# 删除关联的权限
|
# 删除关联的权限(改为对象级删除以触发审计事件)
|
||||||
SysRolePermission.query.filter_by(target_code=menu.code).delete()
|
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
|
||||||
# 删除关联的元素
|
for p in old_perms:
|
||||||
SysElement.query.filter_by(menu_code=menu.code).delete()
|
db.session.delete(p)
|
||||||
|
# 删除关联的元素(改为对象级删除以触发审计事件)
|
||||||
|
old_elements = SysElement.query.filter_by(menu_code=menu.code).all()
|
||||||
|
for e in old_elements:
|
||||||
|
db.session.delete(e)
|
||||||
# 删除菜单
|
# 删除菜单
|
||||||
db.session.delete(menu)
|
db.session.delete(menu)
|
||||||
print(f"🗑️ 已清理旧版库存盘点菜单: {menu.code} ({menu.name})")
|
print(f"🗑️ 已清理旧版库存盘点菜单: {menu.code} ({menu.name})")
|
||||||
@ -456,8 +462,10 @@ class PermissionService:
|
|||||||
).all()
|
).all()
|
||||||
for menu in orphaned_menus:
|
for menu in orphaned_menus:
|
||||||
print(f"🗑️ 清理根级别冗余菜单: {menu.code} ({menu.name})")
|
print(f"🗑️ 清理根级别冗余菜单: {menu.code} ({menu.name})")
|
||||||
# 删除关联的权限
|
# 删除关联的权限(改为对象级删除以触发审计事件)
|
||||||
SysRolePermission.query.filter_by(target_code=menu.code).delete()
|
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
|
||||||
|
for p in old_perms:
|
||||||
|
db.session.delete(p)
|
||||||
db.session.delete(menu)
|
db.session.delete(menu)
|
||||||
|
|
||||||
# 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的)
|
# 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的)
|
||||||
@ -473,13 +481,20 @@ class PermissionService:
|
|||||||
# 保留第一条,删除其他
|
# 保留第一条,删除其他
|
||||||
for dup in duplicates[1:]:
|
for dup in duplicates[1:]:
|
||||||
print(f"🗑️ 清理重复菜单: {dup.code} (id={dup.id}, name={dup.name})")
|
print(f"🗑️ 清理重复菜单: {dup.code} (id={dup.id}, name={dup.name})")
|
||||||
SysRolePermission.query.filter_by(target_code=dup.code).delete()
|
# 改为对象级删除以触发审计事件
|
||||||
SysElement.query.filter_by(menu_code=dup.code).delete()
|
old_perms = SysRolePermission.query.filter_by(target_code=dup.code).all()
|
||||||
|
for p in old_perms:
|
||||||
|
db.session.delete(p)
|
||||||
|
old_elements = SysElement.query.filter_by(menu_code=dup.code).all()
|
||||||
|
for e in old_elements:
|
||||||
|
db.session.delete(e)
|
||||||
db.session.delete(dup)
|
db.session.delete(dup)
|
||||||
|
|
||||||
# 第三步:强制重新设置所有子菜单的 parent_id,确保没有遗漏
|
# 第三步:强制重新设置所有子菜单的 parent_id,确保没有遗漏
|
||||||
# 先将所有子菜单的 parent_id 设为 None,然后重新设置
|
# 改为对象级更新以触发审计事件
|
||||||
SysMenu.query.filter(SysMenu.code.in_(child_codes)).update({SysMenu.parent_id: None})
|
child_menus = SysMenu.query.filter(SysMenu.code.in_(child_codes)).all()
|
||||||
|
for m in child_menus:
|
||||||
|
m.parent_id = None
|
||||||
|
|
||||||
# 创建或更新菜单
|
# 创建或更新菜单
|
||||||
menu_map = {} # code -> menu obj
|
menu_map = {} # code -> menu obj
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
# app/utils/decorators.py
|
# app/utils/decorators.py
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask_jwt_extended import get_jwt, verify_jwt_in_request, get_jwt_identity
|
from flask_jwt_extended import get_jwt, verify_jwt_in_request, get_jwt_identity
|
||||||
from flask import jsonify, g, request
|
from flask import jsonify, g, request, current_app, has_request_context
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
def _verify_token_in_redis():
|
def _verify_token_in_redis():
|
||||||
"""
|
"""
|
||||||
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
|
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
|
||||||
@ -14,31 +13,23 @@ def _verify_token_in_redis():
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
if redis_client is None:
|
if redis_client is None:
|
||||||
# Redis 不可用,跳过验证
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 获取请求中的 Token
|
|
||||||
auth_header = request.headers.get('Authorization', '')
|
auth_header = request.headers.get('Authorization', '')
|
||||||
if not auth_header.startswith('Bearer '):
|
if not auth_header.startswith('Bearer '):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
request_token = auth_header[7:] # 去掉 'Bearer ' 前缀
|
request_token = auth_header[7:]
|
||||||
|
|
||||||
# 获取当前用户 ID
|
|
||||||
claims = get_jwt()
|
claims = get_jwt()
|
||||||
user_id = claims.get('sub')
|
user_id = claims.get('sub')
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 从 Redis 获取存储的 Token
|
|
||||||
stored_token = redis_client.get(f"user_token_{user_id}")
|
stored_token = redis_client.get(f"user_token_{user_id}")
|
||||||
|
|
||||||
# 如果 Redis 中没有存储的 Token(可能是旧登录或 Redis 重启),允许通过
|
|
||||||
if stored_token is None:
|
if stored_token is None:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 比较 Token 是否一致
|
|
||||||
if request_token != stored_token:
|
if request_token != stored_token:
|
||||||
current_app.logger.warning(f"Token mismatch for user {user_id}: request token != stored token")
|
current_app.logger.warning(f"Token mismatch for user {user_id}: request token != stored token")
|
||||||
return False
|
return False
|
||||||
@ -46,25 +37,18 @@ def _verify_token_in_redis():
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Redis token verification error: {e}")
|
current_app.logger.error(f"Redis token verification error: {e}")
|
||||||
# 出错时默认放行,避免影响正常业务
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _raise_token_mismatch_error():
|
def _raise_token_mismatch_error():
|
||||||
"""抛出 Token 不一致的错误(用于单设备登录互踢)"""
|
"""抛出 Token 不一致的错误"""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'msg': '您的账号已在其他设备登录,请重新登录',
|
'msg': '您的账号已在其他设备登录,请重新登录',
|
||||||
'code': 401,
|
'code': 401,
|
||||||
'reason': 'token_mismatch'
|
'reason': 'token_mismatch'
|
||||||
}), 401
|
}), 401
|
||||||
|
|
||||||
|
|
||||||
def role_required(*roles):
|
def role_required(*roles):
|
||||||
"""
|
"""自定义装饰器:检查用户角色"""
|
||||||
自定义装饰器:检查用户角色
|
|
||||||
使用方法: @role_required('super_admin', 'finance')
|
|
||||||
"""
|
|
||||||
|
|
||||||
def wrapper(fn):
|
def wrapper(fn):
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*args, **kwargs):
|
||||||
@ -72,7 +56,6 @@ def role_required(*roles):
|
|||||||
user_role = claims.get('role')
|
user_role = claims.get('role')
|
||||||
user_role_upper = user_role.upper() if user_role else None
|
user_role_upper = user_role.upper() if user_role else None
|
||||||
|
|
||||||
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
|
|
||||||
if user_role_upper == 'SUPER_ADMIN':
|
if user_role_upper == 'SUPER_ADMIN':
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
@ -80,16 +63,11 @@ def role_required(*roles):
|
|||||||
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
||||||
|
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def login_required(fn):
|
def login_required(fn):
|
||||||
"""
|
"""验证 JWT 令牌是否存在且有效"""
|
||||||
验证 JWT 令牌是否存在且有效
|
|
||||||
"""
|
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
@ -98,40 +76,31 @@ def login_required(fn):
|
|||||||
logging.warning(f"JWT verification failed: {e}")
|
logging.warning(f"JWT verification failed: {e}")
|
||||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||||
|
|
||||||
# 单设备登录互踢检查
|
|
||||||
if not _verify_token_in_redis():
|
if not _verify_token_in_redis():
|
||||||
return _raise_token_mismatch_error()
|
return _raise_token_mismatch_error()
|
||||||
|
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def permission_required(permission_code):
|
def permission_required(permission_code):
|
||||||
"""
|
"""检查当前用户是否拥有指定权限码"""
|
||||||
检查当前用户是否拥有指定权限码
|
|
||||||
使用方法: @permission_required('material:base:read')
|
|
||||||
"""
|
|
||||||
def wrapper(fn):
|
def wrapper(fn):
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*args, **kwargs):
|
||||||
# 首先验证 JWT
|
|
||||||
try:
|
try:
|
||||||
verify_jwt_in_request()
|
verify_jwt_in_request()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"JWT verification failed: {e}")
|
logging.warning(f"JWT verification failed: {e}")
|
||||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||||
|
|
||||||
# 单设备登录互踢检查
|
|
||||||
if not _verify_token_in_redis():
|
if not _verify_token_in_redis():
|
||||||
return _raise_token_mismatch_error()
|
return _raise_token_mismatch_error()
|
||||||
|
|
||||||
claims = get_jwt()
|
claims = get_jwt()
|
||||||
user_role = claims.get('role')
|
user_role = claims.get('role')
|
||||||
# 超级管理员放行 (忽略大小写)
|
|
||||||
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
# 根据角色查询数据库中的权限
|
|
||||||
try:
|
try:
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
perm_dict = AuthService.get_user_permissions(user_role)
|
perm_dict = AuthService.get_user_permissions(user_role)
|
||||||
@ -139,192 +108,24 @@ def permission_required(permission_code):
|
|||||||
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
|
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
|
||||||
return jsonify(msg='权限查询失败'), 403
|
return jsonify(msg='权限查询失败'), 403
|
||||||
|
|
||||||
# 合并菜单和元素权限
|
|
||||||
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||||
if permission_code not in all_perms:
|
if permission_code not in all_perms:
|
||||||
# 详细的调试日志
|
logging.warning(f"权限检查失败: 角色={user_role}, 所需权限={permission_code}")
|
||||||
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
|
|
||||||
logging.warning(
|
|
||||||
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
|
|
||||||
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
return decorator
|
return decorator
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
|
||||||
def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
|
|
||||||
"""
|
"""
|
||||||
审计日志装饰器
|
已废弃!
|
||||||
用法: @audit_log(module='inbound_buy', action='create')
|
由 SQLAlchemy 底层监听器(app/core/audit_listener.py)全面接管审计日志入库。
|
||||||
@audit_log(module='bom', action='update', get_target_id_fn=lambda: ..., get_details_fn=lambda req, resp: ...)
|
此装饰器保留空壳以防项目中其他文件 import 引用时报错。
|
||||||
|
|
||||||
升级特性:
|
|
||||||
- 自动捕获请求 Payload 作为变更明细
|
|
||||||
- 自动过滤过长的 Base64 图片数据
|
|
||||||
- 支持自定义 get_details_fn 覆盖默认行为
|
|
||||||
"""
|
"""
|
||||||
# 需要过滤的图片字段
|
|
||||||
IMAGE_FIELDS = {'arrival_photo', 'product_photo', 'photo', 'image', 'signature', 'borrow_signature', 'return_signature'}
|
|
||||||
|
|
||||||
def _filter_payload(payload):
|
|
||||||
"""过滤 Payload 中的大字段,防止数据库膨胀"""
|
|
||||||
if not payload or not isinstance(payload, dict):
|
|
||||||
return payload
|
|
||||||
filtered = {}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if key.lower() in IMAGE_FIELDS and isinstance(value, str) and len(value) > 100:
|
|
||||||
filtered[key] = '[图片数据已省略]'
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
filtered[key] = _filter_payload(value)
|
|
||||||
elif isinstance(value, list):
|
|
||||||
filtered[key] = [
|
|
||||||
_filter_payload(item) if isinstance(item, dict) else item
|
|
||||||
for item in value
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
filtered[key] = value
|
|
||||||
return filtered
|
|
||||||
|
|
||||||
def _get_payload():
|
|
||||||
"""自动获取请求 Payload"""
|
|
||||||
# 尝试 JSON
|
|
||||||
payload = request.get_json(silent=True)
|
|
||||||
if payload:
|
|
||||||
return payload
|
|
||||||
# 尝试 Form Data
|
|
||||||
if request.form:
|
|
||||||
return request.form.to_dict()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def wrapper(fn):
|
def wrapper(fn):
|
||||||
|
from functools import wraps
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*inner_args, **inner_kwargs):
|
||||||
# 获取请求上下文
|
return fn(*inner_args, **inner_kwargs)
|
||||||
claims = get_jwt()
|
|
||||||
user_id = get_jwt_identity()
|
|
||||||
username = claims.get('username', '')
|
|
||||||
display_name = claims.get('display_name', '')
|
|
||||||
|
|
||||||
# ★ 修复 DetachedInstanceError:在 fn() 执行前预先获取用户完整信息
|
|
||||||
# 这样可以避免在 fn() 提交 session 后再访问 User 对象导致游离
|
|
||||||
if not display_name and user_id:
|
|
||||||
try:
|
|
||||||
from app.models.system import SysUser
|
|
||||||
user = SysUser.query.get(user_id)
|
|
||||||
if user:
|
|
||||||
display_name = user.display_name or username
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 预先获取 IP(避免后续访问 request 对象异常)
|
|
||||||
ip_address = request.headers.get('X-Forwarded-For') or request.remote_addr or ''
|
|
||||||
if ip_address and ',' in ip_address:
|
|
||||||
ip_address = ip_address.split(',')[0].strip()
|
|
||||||
|
|
||||||
# 获取请求信息
|
|
||||||
http_method = request.method
|
|
||||||
url = request.url
|
|
||||||
user_agent = request.headers.get('User-Agent', '')[:500]
|
|
||||||
|
|
||||||
# 解析 action(支持动态)
|
|
||||||
final_action = action
|
|
||||||
if callable(action):
|
|
||||||
final_action = action()
|
|
||||||
|
|
||||||
# 预先获取 Payload(用于后续 details 记录)
|
|
||||||
raw_payload = _get_payload()
|
|
||||||
filtered_payload = _filter_payload(raw_payload) if raw_payload else None
|
|
||||||
|
|
||||||
# 执行原函数(此时 Session 可能被提交或回滚)
|
|
||||||
response = fn(*args, **kwargs)
|
|
||||||
|
|
||||||
# 只记录成功的请求(响应状态码 200/201)
|
|
||||||
status_code = 200
|
|
||||||
if hasattr(response, 'status_code'):
|
|
||||||
status_code = response.status_code
|
|
||||||
|
|
||||||
if status_code in [200, 201]:
|
|
||||||
try:
|
|
||||||
from app.models.audit import AuditLog
|
|
||||||
from app.extensions import db
|
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
# ★ 已在上方预先获取 display_name,此处无需再查询 User 对象
|
|
||||||
# 使用预先获取的字符串数据,避免 DetachedInstanceError
|
|
||||||
|
|
||||||
# 获取 target_id
|
|
||||||
target_id = None
|
|
||||||
if get_target_id_fn:
|
|
||||||
try:
|
|
||||||
target_id = get_target_id_fn()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not target_id and hasattr(response, 'json'):
|
|
||||||
resp_data = response.get_json()
|
|
||||||
if resp_data and isinstance(resp_data, dict):
|
|
||||||
target_id = resp_data.get('id')
|
|
||||||
|
|
||||||
# 获取 target_name
|
|
||||||
target_name = None
|
|
||||||
if get_target_name_fn:
|
|
||||||
try:
|
|
||||||
target_name = get_target_name_fn()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# 如果仍未获取到目标名称,尝试从响应 JSON 中常见字段获取
|
|
||||||
if not target_name and hasattr(response, 'json'):
|
|
||||||
resp_data = response.get_json()
|
|
||||||
if resp_data and isinstance(resp_data, dict):
|
|
||||||
# 优先从顶层获取
|
|
||||||
for field in ['order_no', 'outbound_no', 'borrow_no', 'adjustment_no', 'material_name']:
|
|
||||||
if field in resp_data:
|
|
||||||
target_name = resp_data[field]
|
|
||||||
break
|
|
||||||
# 再尝试从 data 字段获取(部分 API 返回格式)
|
|
||||||
if not target_name and 'data' in resp_data:
|
|
||||||
data = resp_data['data']
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for field in ['order_no', 'outbound_no', 'borrow_no', 'adjustment_no', 'material_name']:
|
|
||||||
if field in data:
|
|
||||||
target_name = data[field]
|
|
||||||
break
|
|
||||||
|
|
||||||
# 获取 details
|
|
||||||
details = None
|
|
||||||
if get_details_fn:
|
|
||||||
# 优先使用自定义差异对比函数
|
|
||||||
try:
|
|
||||||
details = get_details_fn(request, response)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif filtered_payload:
|
|
||||||
# 默认:记录请求 Payload
|
|
||||||
details = {'payload': filtered_payload}
|
|
||||||
|
|
||||||
# 保存日志
|
|
||||||
log_entry = AuditLog(
|
|
||||||
user_id=user_id,
|
|
||||||
username=username,
|
|
||||||
display_name=display_name,
|
|
||||||
action=final_action or http_method.lower(),
|
|
||||||
module=module,
|
|
||||||
target_id=str(target_id) if target_id else None,
|
|
||||||
target_name=target_name,
|
|
||||||
details=details,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
method=http_method,
|
|
||||||
url=url,
|
|
||||||
status_code=status_code
|
|
||||||
)
|
|
||||||
db.session.add(log_entry)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.error(f"审计日志记录失败: {str(e)}")
|
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
return wrapper
|
return wrapper
|
||||||
@ -69,6 +69,7 @@
|
|||||||
class="beautified-select"
|
class="beautified-select"
|
||||||
popper-class="bom-loadmore-popper parent-popper"
|
popper-class="bom-loadmore-popper parent-popper"
|
||||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||||||
|
@change="onParentChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in parentOptions"
|
v-for="item in parentOptions"
|
||||||
@ -603,16 +604,40 @@ const loadDetail = async (bomNo: string, version: string) => {
|
|||||||
const res = await getBomDetail(bomNo, version)
|
const res = await getBomDetail(bomNo, version)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const data = res.data
|
const data = res.data
|
||||||
form.parent_id = data.parent_id
|
// 1. 映射子件基本数据
|
||||||
form.version = data.version
|
|
||||||
form.is_enabled = data.is_enabled
|
|
||||||
form.children = data.children.map((child: any) => ({
|
form.children = data.children.map((child: any) => ({
|
||||||
child_id: child.child_id,
|
child_id: child.child_id,
|
||||||
dosage: child.dosage,
|
dosage: child.dosage,
|
||||||
remark: child.remark || ''
|
remark: child.remark || ''
|
||||||
}))
|
}))
|
||||||
// 为每个子件行初始化下拉状态
|
|
||||||
form.children.forEach((_, idx) => initChildDropdownState(idx))
|
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
|
||||||
|
form.children.forEach((child, idx) => {
|
||||||
|
initChildDropdownState(idx)
|
||||||
|
|
||||||
|
if (child.child_id) {
|
||||||
|
const state = childDropdownStates.value.get(idx)!
|
||||||
|
// 从原始 data.children 中取对应的名称和规格注入 options
|
||||||
|
const rawChildData = data.children[idx]
|
||||||
|
state.options = [{
|
||||||
|
id: rawChildData.child_id,
|
||||||
|
name: rawChildData.child_name || '未知物料', // 依赖后端返回 child_name
|
||||||
|
spec: rawChildData.child_spec || '' // 依赖后端返回 child_spec
|
||||||
|
}]
|
||||||
|
state.hasMore = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 处理父件回显,预填充 parentOptions
|
||||||
|
if (data.parent_id) {
|
||||||
|
form.parent_id = data.parent_id
|
||||||
|
parentOptions.value = [{
|
||||||
|
id: data.parent_id,
|
||||||
|
name: data.parent_name || '未知产品', // 依赖后端返回 parent_name
|
||||||
|
spec: data.parent_spec || '' // 依赖后端返回 parent_spec
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
if (data.parent_spec) {
|
if (data.parent_spec) {
|
||||||
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
|
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -498,6 +498,7 @@ const openManualSelect = async () => {
|
|||||||
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
|
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
|
||||||
uniqueKey: `${i.type}_${i.id}`,
|
uniqueKey: `${i.type}_${i.id}`,
|
||||||
available_quantity: parseFloat(i.available_quantity) || 0,
|
available_quantity: parseFloat(i.available_quantity) || 0,
|
||||||
|
availableCount: parseFloat(i.available_quantity) || 0,
|
||||||
export_quantity: 1
|
export_quantity: 1
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -63,7 +63,13 @@
|
|||||||
<el-table-column prop="created_at" label="操作时间" width="170" />
|
<el-table-column prop="created_at" label="操作时间" width="170" />
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button v-if="scope.row.details && Object.keys(scope.row.details).length > 0" link type="primary" size="small" @click="handleViewDetails(scope.row)">
|
<el-button
|
||||||
|
v-if="hasDetailContent(scope.row.details)"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="handleViewDetails(scope.row)"
|
||||||
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</el-button>
|
</el-button>
|
||||||
<span v-else style="color: #909399; font-size: 12px;">无变更明细</span>
|
<span v-else style="color: #909399; font-size: 12px;">无变更明细</span>
|
||||||
@ -84,12 +90,15 @@
|
|||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
<!-- ★ 重写的详情弹窗:支持三种高级结构 ★ -->
|
||||||
<el-dialog v-model="detailDialogVisible" title="操作详情" width="700px" destroy-on-close>
|
<el-dialog v-model="detailDialogVisible" title="操作详情" width="750px" destroy-on-close :close-on-click-modal="false">
|
||||||
<el-descriptions :column="2" border>
|
<!-- 基本信息 -->
|
||||||
|
<el-descriptions :column="2" border class="base-info">
|
||||||
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
|
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>
|
<el-descriptions-item label="操作人">{{ currentLog.username }} ({{ currentLog.display_name }})</el-descriptions-item>
|
||||||
<el-descriptions-item label="模块">{{ currentLog.module }}</el-descriptions-item>
|
<el-descriptions-item label="模块">
|
||||||
|
<el-tag>{{ currentLog.module }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="操作类型">
|
<el-descriptions-item label="操作类型">
|
||||||
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
|
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@ -97,23 +106,92 @@
|
|||||||
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
|
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
|
<el-descriptions-item label="请求方式">{{ currentLog.method }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item>
|
<el-descriptions-item label="操作时间" :span="2">{{ currentLog.created_at }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="请求URL" :span="2">
|
|
||||||
<el-text size="small">{{ currentLog.url }}</el-text>
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<div v-if="currentLog.details" class="details-box">
|
<!-- ★ 变更明细区域(支持同时展示多种结构)★ -->
|
||||||
<div class="details-title">变更内容 (JSON)</div>
|
<div class="details-section">
|
||||||
<pre class="json-content">{{ formatDetails(currentLog.details) }}</pre>
|
|
||||||
|
<!-- 情况1:UPDATE - 变更对比表 -->
|
||||||
|
<div v-if="hasChanges" class="changes-box">
|
||||||
|
<div class="section-title">
|
||||||
|
<el-icon><EditPen /></el-icon>
|
||||||
|
字段变更详情(共 {{ changesList.length }} 处变更)
|
||||||
|
</div>
|
||||||
|
<el-table :data="changesList" border stripe size="small" max-height="350">
|
||||||
|
<el-table-column prop="field" label="字段名" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="field-name">{{ row.field }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="修改前" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="old-value">{{ row.old ?? '空' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="修改后" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="new-value">{{ row.new ?? '空' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 情况2:DELETE - 删除快照 -->
|
||||||
|
<div v-if="hasDeletedSnapshot" class="snapshot-box">
|
||||||
|
<div class="section-title">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除前的数据快照
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item
|
||||||
|
v-for="(value, key) in deletedSnapshot"
|
||||||
|
:key="String(key)"
|
||||||
|
:label="String(key)"
|
||||||
|
:span="isLongValue(value) ? 2 : 1"
|
||||||
|
>
|
||||||
|
<span class="snapshot-value">{{ formatValue(value) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 情况3:CREATE - 新增详情 -->
|
||||||
|
<div v-if="hasCreated" class="snapshot-box">
|
||||||
|
<div class="section-title">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增的数据详情
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item
|
||||||
|
v-for="(value, key) in createdData"
|
||||||
|
:key="String(key)"
|
||||||
|
:label="String(key)"
|
||||||
|
:span="isLongValue(value) ? 2 : 1"
|
||||||
|
>
|
||||||
|
<span class="snapshot-value">{{ formatValue(value) }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 兜底:原始 JSON(仅在没有任何高级结构时显示) -->
|
||||||
|
<div v-if="showRawJson" class="raw-json-box">
|
||||||
|
<div class="section-title">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
原始数据
|
||||||
|
</div>
|
||||||
|
<pre class="raw-json">{{ rawJson }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { getAuditLogs, getAuditModules } from '@/api/audit'
|
import { getAuditLogs, getAuditModules } from '@/api/audit'
|
||||||
|
|
||||||
// 表格数据
|
// 表格数据
|
||||||
@ -144,7 +222,91 @@ const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'im
|
|||||||
const detailDialogVisible = ref(false)
|
const detailDialogVisible = ref(false)
|
||||||
const currentLog = ref<any>({})
|
const currentLog = ref<any>({})
|
||||||
|
|
||||||
// 获取操作类型对应的标签样式
|
// ============================================================
|
||||||
|
// 详情解析逻辑
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 辅助函数:判断 details 是否有可显示内容
|
||||||
|
const hasDetailContent = (details: any): boolean => {
|
||||||
|
if (!details || typeof details !== 'object') return false
|
||||||
|
if (Object.keys(details).length === 0) return false
|
||||||
|
return !!(
|
||||||
|
details.changes ||
|
||||||
|
details.deleted_snapshot ||
|
||||||
|
details.created ||
|
||||||
|
details.payload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否存在各高级结构
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
return !!(details?.changes && typeof details.changes === 'object')
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasDeletedSnapshot = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
return !!(details?.deleted_snapshot && typeof details.deleted_snapshot === 'object')
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCreated = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
return !!(details?.created && typeof details.created === 'object')
|
||||||
|
})
|
||||||
|
|
||||||
|
const showRawJson = computed(() => {
|
||||||
|
return !hasChanges.value && !hasDeletedSnapshot.value && !hasCreated.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析 changes 为表格数据
|
||||||
|
const changesList = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
if (!details?.changes) return []
|
||||||
|
|
||||||
|
return Object.entries(details.changes).map(([field, values]: [string, any]) => ({
|
||||||
|
field,
|
||||||
|
old: values?.old,
|
||||||
|
new: values?.new
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析 deleted_snapshot
|
||||||
|
const deletedSnapshot = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
return details?.deleted_snapshot || {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析 created
|
||||||
|
const createdData = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
return details?.created || {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 原始 JSON
|
||||||
|
const rawJson = computed(() => {
|
||||||
|
const details = currentLog.value.details
|
||||||
|
if (!details) return ''
|
||||||
|
return JSON.stringify(details, null, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 辅助函数:格式化值
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数:判断是否是长值
|
||||||
|
const isLongValue = (value: any): boolean => {
|
||||||
|
if (value === null || value === undefined) return false
|
||||||
|
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||||
|
return str.length > 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 其他方法
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
const getActionType = (action: string) => {
|
const getActionType = (action: string) => {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
'create': 'success',
|
'create': 'success',
|
||||||
@ -158,11 +320,9 @@ const getActionType = (action: string) => {
|
|||||||
return typeMap[action?.toLowerCase()] || 'info'
|
return typeMap[action?.toLowerCase()] || 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
const getList = async () => {
|
const getList = async () => {
|
||||||
tableLoading.value = true
|
tableLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 处理日期范围
|
|
||||||
if (dateRange.value && dateRange.value.length === 2) {
|
if (dateRange.value && dateRange.value.length === 2) {
|
||||||
queryParams.start_date = dateRange.value[0]
|
queryParams.start_date = dateRange.value[0]
|
||||||
queryParams.end_date = dateRange.value[1]
|
queryParams.end_date = dateRange.value[1]
|
||||||
@ -175,7 +335,6 @@ const getList = async () => {
|
|||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
tableData.value = res.data.list
|
tableData.value = res.data.list
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
// 更新选项
|
|
||||||
if (res.data.modules) {
|
if (res.data.modules) {
|
||||||
moduleOptions.value = res.data.modules
|
moduleOptions.value = res.data.modules
|
||||||
}
|
}
|
||||||
@ -190,13 +349,11 @@ const getList = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const handleQuery = () => {
|
const handleQuery = () => {
|
||||||
queryParams.page = 1
|
queryParams.page = 1
|
||||||
getList()
|
getList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
queryParams.page = 1
|
queryParams.page = 1
|
||||||
queryParams.username = ''
|
queryParams.username = ''
|
||||||
@ -209,29 +366,13 @@ const handleReset = () => {
|
|||||||
getList()
|
getList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看详情
|
|
||||||
const handleViewDetails = (row: any) => {
|
const handleViewDetails = (row: any) => {
|
||||||
currentLog.value = row
|
currentLog.value = row
|
||||||
detailDialogVisible.value = true
|
detailDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化详情 JSON
|
|
||||||
const formatDetails = (details: any) => {
|
|
||||||
if (!details) return ''
|
|
||||||
if (typeof details === 'string') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(details), null, 2)
|
|
||||||
} catch {
|
|
||||||
return details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return JSON.stringify(details, null, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getList()
|
getList()
|
||||||
// 获取可选模块
|
|
||||||
getAuditModules().then(res => {
|
getAuditModules().then(res => {
|
||||||
if (res.code === 200 && res.data) {
|
if (res.code === 200 && res.data) {
|
||||||
moduleOptions.value = res.data
|
moduleOptions.value = res.data
|
||||||
@ -251,26 +392,79 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-gray {
|
.base-info {
|
||||||
color: #999;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-box {
|
.details-section {
|
||||||
margin-top: 20px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-title {
|
.section-title {
|
||||||
font-weight: bold;
|
display: flex;
|
||||||
margin-bottom: 10px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-content {
|
/* 变更表格样式 */
|
||||||
background-color: #f5f7fa;
|
.changes-box {
|
||||||
padding: 15px;
|
background: #fef0f0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #fbc4c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.old-value {
|
||||||
|
color: #f56c6c;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-value {
|
||||||
|
color: #67c23a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快照样式 */
|
||||||
|
.snapshot-box {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snapshot-value {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 原始 JSON */
|
||||||
|
.raw-json-box {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-json {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #67c23a;
|
||||||
|
padding: 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -62,6 +62,28 @@
|
|||||||
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
|
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增:拉取其他角色权限的操作区 -->
|
||||||
|
<div class="pull-permission-bar">
|
||||||
|
<span class="pull-label">从其他角色复制权限:</span>
|
||||||
|
<el-select
|
||||||
|
v-model="sourceRoleForClone"
|
||||||
|
placeholder="选择源角色..."
|
||||||
|
clearable
|
||||||
|
size="default"
|
||||||
|
@change="handleClonePermissions"
|
||||||
|
class="clone-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="role in availableSourceRoles"
|
||||||
|
:key="role.value"
|
||||||
|
:label="role.label"
|
||||||
|
:value="role.value"
|
||||||
|
:disabled="role.value === currentRole"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span class="pull-hint" v-if="!sourceRoleForClone">选择后将覆盖当前未保存的勾选状态</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@ -78,20 +100,25 @@
|
|||||||
|
|
||||||
<el-table-column label="访问权限" width="150" align="center">
|
<el-table-column label="访问权限" width="150" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<!-- 父级目录隐藏复选框,仅叶子节点可操作 -->
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
|
v-if="row.type !== 'menu' || !row.children?.length"
|
||||||
v-model="row.hasRead"
|
v-model="row.hasRead"
|
||||||
@change="(val) => handleReadChange(val, row)"
|
@change="(val) => handleReadChange(val, row)"
|
||||||
class="custom-checkbox"
|
class="custom-checkbox"
|
||||||
>
|
>
|
||||||
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
|
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
|
<span v-else class="text-gray">-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作权限" width="180" align="center">
|
<el-table-column label="操作权限" width="180" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.operationCode">
|
<!-- 父级目录隐藏操作权限列 -->
|
||||||
|
<div v-if="row.type !== 'menu' || !row.children?.length">
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
|
v-if="row.operationCode"
|
||||||
v-model="row.hasWrite"
|
v-model="row.hasWrite"
|
||||||
:disabled="!row.hasRead"
|
:disabled="!row.hasRead"
|
||||||
@change="(val) => handleWriteChange(val, row)"
|
@change="(val) => handleWriteChange(val, row)"
|
||||||
@ -99,6 +126,7 @@
|
|||||||
>
|
>
|
||||||
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead }">可编辑 (Write)</span>
|
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead }">可编辑 (Write)</span>
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
|
<span v-else class="text-gray">-</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="text-gray">-</span>
|
<span v-else class="text-gray">-</span>
|
||||||
</template>
|
</template>
|
||||||
@ -153,8 +181,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
|
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
|
||||||
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
|
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
|
||||||
|
|
||||||
@ -180,6 +208,7 @@ interface PermissionNode {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const currentRole = ref('')
|
const currentRole = ref('')
|
||||||
|
const sourceRoleForClone = ref('') // 用于克隆权限的源角色
|
||||||
const roleList = [
|
const roleList = [
|
||||||
{ label: '超级管理员', value: 'SUPER_ADMIN' },
|
{ label: '超级管理员', value: 'SUPER_ADMIN' },
|
||||||
{ label: '主管', value: 'SUPERVISOR' },
|
{ label: '主管', value: 'SUPERVISOR' },
|
||||||
@ -269,6 +298,8 @@ const transformData = (nodes: any[]): PermissionNode[] => {
|
|||||||
// 2. 切换角色:回显权限
|
// 2. 切换角色:回显权限
|
||||||
const handleRoleSelect = async (roleCode: string) => {
|
const handleRoleSelect = async (roleCode: string) => {
|
||||||
currentRole.value = roleCode
|
currentRole.value = roleCode
|
||||||
|
// 切换角色时清空克隆选择
|
||||||
|
sourceRoleForClone.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -286,6 +317,53 @@ const handleRoleSelect = async (roleCode: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 可用的源角色列表(排除当前已选角色)
|
||||||
|
const availableSourceRoles = computed(() => {
|
||||||
|
return roleList.filter(r => r.value !== currentRole.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ★ 新增:从其他角色拉取权限
|
||||||
|
const handleClonePermissions = async (sourceRole: string) => {
|
||||||
|
if (!sourceRole) {
|
||||||
|
sourceRoleForClone.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认提示
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将从角色【${getRoleLabel(sourceRole)}】复制权限到【${getRoleLabel(currentRole.value)}】,覆盖当前未保存的勾选状态,是否继续?`,
|
||||||
|
'权限拉取确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认拉取',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
sourceRoleForClone.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 调用后端 API 获取源角色的权限
|
||||||
|
const res: any = await getRolePermissions(sourceRole)
|
||||||
|
if (res.code === 200) {
|
||||||
|
const perms = new Set([...(res.data.menus || []), ...(res.data.elements || [])])
|
||||||
|
// 递归设置表格每一行的状态
|
||||||
|
setRowStatus(tableData.value, perms)
|
||||||
|
ElMessage.success(`已从【${getRoleLabel(sourceRole)}】拉取权限,请修改后点击保存`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('拉取权限失败')
|
||||||
|
} finally {
|
||||||
|
sourceRoleForClone.value = '' // 清空选择器
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 递归回显状态
|
// 递归回显状态
|
||||||
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
|
const setRowStatus = (rows: PermissionNode[], perms: Set<any>) => {
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
@ -599,6 +677,33 @@ onMounted(() => {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 新增:拉取权限操作条 */
|
||||||
|
.pull-permission-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d9ecff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
/* 表格内样式 */
|
/* 表格内样式 */
|
||||||
.custom-checkbox {
|
.custom-checkbox {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user