Compare commits

10 Commits

11 changed files with 672 additions and 294 deletions

View File

@ -218,8 +218,8 @@ def delete_bom(bom_no):
if not exist:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 删除
query.delete()
# 删除(改为对象级删除以触发审计事件)
db.session.delete(exist)
db.session.commit()
return jsonify({
'code': 200,

View File

@ -444,7 +444,11 @@ def clear_draft():
# 清除指定会话
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()
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
@ -461,8 +465,11 @@ def start_new_session():
清空整张草稿表,返回新的 session_id
"""
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()
# 生成新的 session_id
@ -1148,10 +1155,14 @@ def generate_missing_stocktake():
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
# 特征user_id == 'system' (表示由系统自动生成)
deleted_count = StocktakeDraft.query.filter(
# 改为对象级删除以触发审计事件
system_records = StocktakeDraft.query.filter(
StocktakeDraft.session_id == session_id,
StocktakeDraft.user_id == 'system'
).delete()
).all()
deleted_count = len(system_records)
for rec in system_records:
db.session.delete(rec)
if deleted_count > 0:
db.session.commit()
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")

View 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

View File

@ -47,3 +47,13 @@ def init_extensions(app):
app.logger.info("✅ Redis connected successfully")
except Exception as e:
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}")

View File

@ -189,8 +189,10 @@ class BomService:
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:
bom = BomTable(
@ -260,7 +262,11 @@ class BomService:
existing = BomTable.query.filter_by(parent_id=parent_id).first()
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:
bom = BomTable(
bom_no=bom_no, version=version, parent_id=parent_id,

View File

@ -109,8 +109,10 @@ class PermissionService:
try:
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
# 2. 删除该角色旧的所有权限
SysRolePermission.query.filter_by(role_code=role_code).delete()
# 2. 删除该角色旧的所有权限(改为对象级删除以触发审计事件)
old_perms = SysRolePermission.query.filter_by(role_code=role_code).all()
for p in old_perms:
db.session.delete(p)
# 3. 准备新数据
if permissions:
@ -374,10 +376,14 @@ class PermissionService:
).all()
for menu in legacy_menus:
# 删除关联的权限
SysRolePermission.query.filter_by(target_code=menu.code).delete()
# 删除关联的元素
SysElement.query.filter_by(menu_code=menu.code).delete()
# 删除关联的权限(改为对象级删除以触发审计事件)
old_perms = SysRolePermission.query.filter_by(target_code=menu.code).all()
for p in old_perms:
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)
print(f"🗑️ 已清理旧版库存盘点菜单: {menu.code} ({menu.name})")
@ -456,8 +462,10 @@ class PermissionService:
).all()
for menu in orphaned_menus:
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)
# 第二步:清理重复菜单(同一个 code 存在多条记录,保留 ID 最小的)
@ -473,13 +481,20 @@ class PermissionService:
# 保留第一条,删除其他
for dup in duplicates[1:]:
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)
# 第三步:强制重新设置所有子菜单的 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

View File

@ -1,11 +1,10 @@
# app/utils/decorators.py
from functools import wraps
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 json
def _verify_token_in_redis():
"""
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
@ -14,31 +13,23 @@ def _verify_token_in_redis():
from flask import current_app
if redis_client is None:
# Redis 不可用,跳过验证
return True
try:
# 获取请求中的 Token
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return True
request_token = auth_header[7:] # 去掉 'Bearer ' 前缀
# 获取当前用户 ID
request_token = auth_header[7:]
claims = get_jwt()
user_id = claims.get('sub')
if user_id is None:
return True
# 从 Redis 获取存储的 Token
stored_token = redis_client.get(f"user_token_{user_id}")
# 如果 Redis 中没有存储的 Token可能是旧登录或 Redis 重启),允许通过
if stored_token is None:
return True
# 比较 Token 是否一致
if request_token != stored_token:
current_app.logger.warning(f"Token mismatch for user {user_id}: request token != stored token")
return False
@ -46,25 +37,18 @@ def _verify_token_in_redis():
return True
except Exception as e:
current_app.logger.error(f"Redis token verification error: {e}")
# 出错时默认放行,避免影响正常业务
return True
def _raise_token_mismatch_error():
"""抛出 Token 不一致的错误(用于单设备登录互踢)"""
"""抛出 Token 不一致的错误"""
return jsonify({
'msg': '您的账号已在其他设备登录,请重新登录',
'code': 401,
'reason': 'token_mismatch'
}), 401
def role_required(*roles):
"""
自定义装饰器:检查用户角色
使用方法: @role_required('super_admin', 'finance')
"""
"""自定义装饰器:检查用户角色"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
@ -72,7 +56,6 @@ def role_required(*roles):
user_role = claims.get('role')
user_role_upper = user_role.upper() if user_role else None
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
if user_role_upper == 'SUPER_ADMIN':
return fn(*args, **kwargs)
@ -80,16 +63,11 @@ def role_required(*roles):
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
def login_required(fn):
"""
验证 JWT 令牌是否存在且有效
"""
"""验证 JWT 令牌是否存在且有效"""
@wraps(fn)
def decorator(*args, **kwargs):
try:
@ -98,40 +76,31 @@ def login_required(fn):
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# 单设备登录互踢检查
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
return fn(*args, **kwargs)
return decorator
def permission_required(permission_code):
"""
检查当前用户是否拥有指定权限码
使用方法: @permission_required('material:base:read')
"""
"""检查当前用户是否拥有指定权限码"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
# 首先验证 JWT
try:
verify_jwt_in_request()
except Exception as e:
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# 单设备登录互踢检查
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
claims = get_jwt()
user_role = claims.get('role')
# 超级管理员放行 (忽略大小写)
if user_role and user_role.upper() == 'SUPER_ADMIN':
return fn(*args, **kwargs)
# 根据角色查询数据库中的权限
try:
from app.services.auth_service import AuthService
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}")
return jsonify(msg='权限查询失败'), 403
# 合并菜单和元素权限
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
if permission_code not in all_perms:
# 详细的调试日志
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
logging.warning(
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
logging.warning(f"权限检查失败: 角色={user_role}, 所需权限={permission_code}")
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
def audit_log(module: str, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
"""
审计日志装饰器
用法: @audit_log(module='inbound_buy', action='create')
@audit_log(module='bom', action='update', get_target_id_fn=lambda: ..., get_details_fn=lambda req, resp: ...)
升级特性:
- 自动捕获请求 Payload 作为变更明细
- 自动过滤过长的 Base64 图片数据
- 支持自定义 get_details_fn 覆盖默认行为
已废弃!
由 SQLAlchemy 底层监听器app/core/audit_listener.py全面接管审计日志入库。
此装饰器保留空壳以防项目中其他文件 import 引用时报错。
"""
# 需要过滤的图片字段
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):
from functools import wraps
@wraps(fn)
def decorator(*args, **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
def decorator(*inner_args, **inner_kwargs):
return fn(*inner_args, **inner_kwargs)
return decorator
return wrapper

View File

@ -69,6 +69,7 @@
class="beautified-select"
popper-class="bom-loadmore-popper parent-popper"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
@change="onParentChange"
>
<el-option
v-for="item in parentOptions"
@ -603,16 +604,40 @@ const loadDetail = async (bomNo: string, version: string) => {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
const data = res.data
form.parent_id = data.parent_id
form.version = data.version
form.is_enabled = data.is_enabled
// 1. 映射子件基本数据
form.children = data.children.map((child: any) => ({
child_id: child.child_id,
dosage: child.dosage,
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) {
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
} else {

View File

@ -498,6 +498,7 @@ const openManualSelect = async () => {
warehouse_location: i.warehouse_location || i.warehouse_loc || i.full_path || '',
uniqueKey: `${i.type}_${i.id}`,
available_quantity: parseFloat(i.available_quantity) || 0,
availableCount: parseFloat(i.available_quantity) || 0,
export_quantity: 1
}))
} catch (e) {

View File

@ -63,7 +63,13 @@
<el-table-column prop="created_at" label="操作时间" width="170" />
<el-table-column label="操作" width="120" fixed="right">
<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>
<span v-else style="color: #909399; font-size: 12px;">无变更明细</span>
@ -84,12 +90,15 @@
/>
</el-card>
<!-- 详情弹窗 -->
<el-dialog v-model="detailDialogVisible" title="操作详情" width="700px" destroy-on-close>
<el-descriptions :column="2" border>
<!-- 重写的详情弹窗支持三种高级结构 -->
<el-dialog v-model="detailDialogVisible" title="操作详情" width="750px" destroy-on-close :close-on-click-modal="false">
<!-- 基本信息 -->
<el-descriptions :column="2" border class="base-info">
<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.module }}</el-descriptions-item>
<el-descriptions-item label="模块">
<el-tag>{{ currentLog.module }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作类型">
<el-tag :type="getActionType(currentLog.action)">{{ currentLog.action }}</el-tag>
</el-descriptions-item>
@ -97,23 +106,92 @@
<el-descriptions-item label="IP地址">{{ currentLog.ip_address }}</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="请求URL" :span="2">
<el-text size="small">{{ currentLog.url }}</el-text>
</el-descriptions-item>
</el-descriptions>
<div v-if="currentLog.details" class="details-box">
<div class="details-title">变更内容 (JSON)</div>
<pre class="json-content">{{ formatDetails(currentLog.details) }}</pre>
<!-- 变更明细区域支持同时展示多种结构 -->
<div class="details-section">
<!-- 情况1UPDATE - 变更对比表 -->
<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>
<!-- 情况2DELETE - 删除快照 -->
<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>
<!-- 情况3CREATE - 新增详情 -->
<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>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { ref, reactive, onMounted, computed } from 'vue'
import { Search, Refresh, EditPen, Delete, Plus, Document } from '@element-plus/icons-vue'
import { getAuditLogs, getAuditModules } from '@/api/audit'
// 表格数据
@ -144,7 +222,91 @@ const actionOptions = ref<string[]>(['create', 'update', 'delete', 'export', 'im
const detailDialogVisible = ref(false)
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 typeMap: Record<string, string> = {
'create': 'success',
@ -158,11 +320,9 @@ const getActionType = (action: string) => {
return typeMap[action?.toLowerCase()] || 'info'
}
// 加载数据
const getList = async () => {
tableLoading.value = true
try {
// 处理日期范围
if (dateRange.value && dateRange.value.length === 2) {
queryParams.start_date = dateRange.value[0]
queryParams.end_date = dateRange.value[1]
@ -175,7 +335,6 @@ const getList = async () => {
if (res.code === 200) {
tableData.value = res.data.list
total.value = res.data.total
// 更新选项
if (res.data.modules) {
moduleOptions.value = res.data.modules
}
@ -190,13 +349,11 @@ const getList = async () => {
}
}
// 搜索
const handleQuery = () => {
queryParams.page = 1
getList()
}
// 重置
const handleReset = () => {
queryParams.page = 1
queryParams.username = ''
@ -209,29 +366,13 @@ const handleReset = () => {
getList()
}
// 查看详情
const handleViewDetails = (row: any) => {
currentLog.value = row
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(() => {
getList()
// 获取可选模块
getAuditModules().then(res => {
if (res.code === 200 && res.data) {
moduleOptions.value = res.data
@ -251,26 +392,79 @@ onMounted(() => {
align-items: center;
}
.text-gray {
color: #999;
.base-info {
margin-bottom: 16px;
}
.details-box {
margin-top: 20px;
.details-section {
margin-top: 10px;
}
.details-title {
font-weight: bold;
margin-bottom: 10px;
.section-title {
display: flex;
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;
padding: 15px;
/* 变更表格样式 */
.changes-box {
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;
max-height: 400px;
overflow: auto;
font-size: 12px;
line-height: 1.5;
max-height: 300px;
overflow: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@ -62,6 +62,28 @@
<el-tag type="warning" effect="plain" round>当前配置: {{ getRoleLabel(currentRole) }}</el-tag>
</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
:data="tableData"
row-key="id"
@ -78,20 +100,25 @@
<el-table-column label="访问权限" width="150" align="center">
<template #default="{ row }">
<!-- 父级目录隐藏复选框仅叶子节点可操作 -->
<el-checkbox
v-if="row.type !== 'menu' || !row.children?.length"
v-model="row.hasRead"
@change="(val) => handleReadChange(val, row)"
class="custom-checkbox"
>
<span :class="{ 'text-active': row.hasRead }">可见 (Read)</span>
</el-checkbox>
<span v-else class="text-gray">-</span>
</template>
</el-table-column>
<el-table-column label="操作权限" width="180" align="center">
<template #default="{ row }">
<div v-if="row.operationCode">
<!-- 父级目录隐藏操作权限列 -->
<div v-if="row.type !== 'menu' || !row.children?.length">
<el-checkbox
v-if="row.operationCode"
v-model="row.hasWrite"
:disabled="!row.hasRead"
@change="(val) => handleWriteChange(val, row)"
@ -99,6 +126,7 @@
>
<span :class="{ 'text-active': row.hasWrite, 'text-disabled': !row.hasRead }">可编辑 (Write)</span>
</el-checkbox>
<span v-else class="text-gray">-</span>
</div>
<span v-else class="text-gray">-</span>
</template>
@ -153,8 +181,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, UserFilled, ArrowRight, Setting, Check, Avatar } from '@element-plus/icons-vue'
import { getAllPermissionTree, getRolePermissions, saveRolePermissions } from '@/api/system/permission'
@ -180,6 +208,7 @@ interface PermissionNode {
const loading = ref(false)
const saving = ref(false)
const currentRole = ref('')
const sourceRoleForClone = ref('') // 用于克隆权限的源角色
const roleList = [
{ label: '超级管理员', value: 'SUPER_ADMIN' },
{ label: '主管', value: 'SUPERVISOR' },
@ -269,6 +298,8 @@ const transformData = (nodes: any[]): PermissionNode[] => {
// 2. 切换角色:回显权限
const handleRoleSelect = async (roleCode: string) => {
currentRole.value = roleCode
// 切换角色时清空克隆选择
sourceRoleForClone.value = ''
loading.value = true
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>) => {
rows.forEach(row => {
@ -599,6 +677,33 @@ onMounted(() => {
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 {
height: auto;