11 Commits

22 changed files with 1674 additions and 505 deletions

View File

@ -1,7 +1,9 @@
# 文件路径: app/api/v1/inbound/base.py # 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, send_file
from app.services.inbound.base_service import MaterialBaseService from app.services.inbound.base_service import MaterialBaseService
import traceback import traceback
import datetime
inbound_base_bp = Blueprint('stock_base', __name__) inbound_base_bp = Blueprint('stock_base', __name__)
@ -26,12 +28,13 @@ def search_base():
@inbound_base_bp.route('/list', methods=['GET']) @inbound_base_bp.route('/list', methods=['GET'])
def get_list(): def get_list():
try: try:
page = request.args.get('pageNum', 1, type=int) # 前端传的是 pageNum page = request.args.get('pageNum', 1, type=int)
limit = request.args.get('pageSize', 10, type=int) limit = request.args.get('pageSize', 10, type=int)
# 构造筛选条件 # 构造筛选条件
filters = { filters = {
'keyword': request.args.get('keyword', ''), 'keyword': request.args.get('keyword', ''),
'company': request.args.get('company', ''),
'category': request.args.get('category', ''), 'category': request.args.get('category', ''),
'type': request.args.get('type', ''), 'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None) 'isEnabled': request.args.get('isEnabled', None)
@ -45,7 +48,7 @@ def get_list():
# ============================================================================== # ==============================================================================
# 2.1 选项接口 (GET /api/v1/inbound/base/options) [新增] # 2.1 选项接口 (GET /api/v1/inbound/base/options)
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/options', methods=['GET']) @inbound_base_bp.route('/options', methods=['GET'])
def get_options(): def get_options():
@ -57,9 +60,45 @@ def get_options():
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 2.2 导出接口 (GET /api/v1/inbound/base/export)
# ==============================================================================
@inbound_base_bp.route('/export', methods=['GET'])
def export_data():
try:
# 获取筛选条件
filters = {
'keyword': request.args.get('keyword', ''),
'company': request.args.get('company', ''),
'category': request.args.get('category', ''),
'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None)
}
# 生成 Excel 文件流
file_stream = MaterialBaseService.export_excel(filters)
# 生成文件名:库存统计+年月日+时分秒 (北京时间 UTC+8)
# 简单处理UTC时间 + 8小时
beijing_time = datetime.datetime.utcnow() + datetime.timedelta(hours=8)
filename = f"库存统计_{beijing_time.strftime('%Y%m%d_%H%M%S')}.xlsx"
# 发送文件
# 注意download_name 仅在较新 Flask 版本有效,旧版本可能需要手动 header
# 但通常浏览器下载名由前端 Blob 处理或 Content-Disposition 决定。
return send_file(
file_stream,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": f"导出失败: {str(e)}"}), 500
# ============================================================================== # ==============================================================================
# 3. 新增接口 (POST /api/v1/inbound/base/) # 3. 新增接口 (POST /api/v1/inbound/base/)
# 注意:前端 material_base.ts 可能会请求 / 或 /add这里统一匹配
# ============================================================================== # ==============================================================================
@inbound_base_bp.route('/', methods=['POST']) @inbound_base_bp.route('/', methods=['POST'])
def create(): def create():

View File

@ -12,6 +12,9 @@ class MaterialBase(db.Model):
# 1. 基础字段 # 1. 基础字段
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# [修改] 所属公司,去除了 default='IRIS'
company_name = db.Column(db.String(255), comment='所属公司')
name = db.Column(db.String(255), nullable=False, comment='名称') name = db.Column(db.String(255), nullable=False, comment='名称')
common_name = db.Column(db.String(255), comment='俗名') common_name = db.Column(db.String(255), comment='俗名')
category = db.Column(db.String(100), comment='类别') category = db.Column(db.String(100), comment='类别')
@ -42,8 +45,7 @@ class MaterialBase(db.Model):
# 3. 关联成品库存 (StockProduct) # 3. 关联成品库存 (StockProduct)
stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic') stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic')
# 4. 关联服务库存 (StockService) - [新增] # 4. 关联服务库存 (StockService)
# 假设您的服务库存模型类名为 StockService且有 base_id 外键
stock_services = db.relationship('StockService', back_populates='base', lazy='dynamic') stock_services = db.relationship('StockService', back_populates='base', lazy='dynamic')
def to_dict(self): def to_dict(self):
@ -65,6 +67,7 @@ class MaterialBase(db.Model):
return { return {
'id': self.id, 'id': self.id,
'companyName': self.company_name,
'name': self.name, 'name': self.name,
'commonName': self.common_name, 'commonName': self.common_name,
'category': self.category, 'category': self.category,
@ -72,7 +75,6 @@ class MaterialBase(db.Model):
'spec': self.spec_model, 'spec': self.spec_model,
'unit': self.unit, 'unit': self.unit,
'visibilityLevel': self.visibility_level, 'visibilityLevel': self.visibility_level,
# 修改:解析为列表返回
'generalManual': parse_list(self.manual_link), 'generalManual': parse_list(self.manual_link),
'generalImage': parse_list(self.product_image), 'generalImage': parse_list(self.product_image),
'isEnabled': 1 if self.is_enabled else 0, 'isEnabled': 1 if self.is_enabled else 0,

View File

@ -4,6 +4,7 @@ import json
# 显式导入 MaterialBase 以防 relationship 找不到引用 # 显式导入 MaterialBase 以防 relationship 找不到引用
from app.models.base import MaterialBase from app.models.base import MaterialBase
class StockBuy(db.Model): class StockBuy(db.Model):
""" """
采购入库库存表 采购入库库存表
@ -32,35 +33,36 @@ class StockBuy(db.Model):
available_quantity = db.Column(db.Numeric(19, 4), default=0) available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 财务与商务 # 财务与商务
unit_price = db.Column(db.Numeric(19, 4), default=0) unit_price = db.Column(db.Numeric(19, 4), default=0) # 现意为:不含税单价
total_price = db.Column(db.Numeric(19, 4), default=0) total_price = db.Column(db.Numeric(19, 4), default=0) # 总价
# [新增] 税率
tax_rate = db.Column(db.Numeric(5, 2), default=0)
currency = db.Column(db.String(20), default='CNY') currency = db.Column(db.String(20), default='CNY')
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0) exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
supplier_name = db.Column(db.String(255)) supplier_name = db.Column(db.String(255))
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name buyer_name = db.Column(db.String(100))
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email buyer_email = db.Column(db.String(100))
original_link = db.Column(db.Text) # 对应 SQL: original_link original_link = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
# 图片字段 (存储 JSON 字符串) # 图片字段 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text) arrival_photo = db.Column(db.Text)
# [新增] 检测报告图片路径 (存储 JSON 字符串)
inspection_report = db.Column(db.Text) inspection_report = db.Column(db.Text)
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq) # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 [已修改] # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_buys') base = db.relationship('MaterialBase', back_populates='stock_buys')
def to_dict(self): def to_dict(self):
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List # 辅助解析函数
def parse_img_list(json_str): def parse_img_list(json_str):
if not json_str: if not json_str:
return [] return []
try: try:
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL则包装成 list
if not json_str.startswith('['): if not json_str.startswith('['):
return [json_str] return [json_str]
return json.loads(json_str) return json.loads(json_str)
@ -70,7 +72,9 @@ class StockBuy(db.Model):
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_id, 'base_id': self.base_id,
# [已修改] 使用 self.base
# [修改] 增加公司名称
'company_name': self.base.company_name if self.base else '',
'material_name': self.base.name if self.base else '', 'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '', 'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category if self.base else '', 'category': self.base.category if self.base else '',
@ -95,6 +99,9 @@ class StockBuy(db.Model):
'unit_price': float(self.unit_price or 0), 'unit_price': float(self.unit_price or 0),
'total_price': float(self.total_price or 0), 'total_price': float(self.total_price or 0),
# [新增] 税率
'tax_rate': float(self.tax_rate or 0),
'currency': self.currency, 'currency': self.currency,
'exchange_rate': float(self.exchange_rate or 1.0), 'exchange_rate': float(self.exchange_rate or 1.0),
@ -104,11 +111,9 @@ class StockBuy(db.Model):
'source_link': self.original_link, 'source_link': self.original_link,
'detail_link': self.detail_link, 'detail_link': self.detail_link,
# [修改] 解析为数组返回给前端
'arrival_photo': parse_img_list(self.arrival_photo), 'arrival_photo': parse_img_list(self.arrival_photo),
'inspection_report': parse_img_list(self.inspection_report), 'inspection_report': parse_img_list(self.inspection_report),
# [新增] 返回全局打印ID及其格式化字符串
'global_print_id': self.global_print_id, 'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else "" 'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
} }

View File

@ -3,6 +3,7 @@ from app.extensions import db
import json import json
from app.models.base import MaterialBase from app.models.base import MaterialBase
class StockProduct(db.Model): class StockProduct(db.Model):
""" """
成品入库库存表 成品入库库存表
@ -44,7 +45,7 @@ class StockProduct(db.Model):
quality_report_link = db.Column(db.Text) # 质量报告 quality_report_link = db.Column(db.Text) # 质量报告
inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON) inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON)
# [新增] 成品实拍图 (JSON 存储) # 成品实拍图 (JSON 存储)
product_photo = db.Column(db.Text) product_photo = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
@ -57,7 +58,7 @@ class StockProduct(db.Model):
# 全局打印流水号 # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 [已修改] # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_products') base = db.relationship('MaterialBase', back_populates='stock_products')
def to_dict(self): def to_dict(self):
@ -79,7 +80,9 @@ class StockProduct(db.Model):
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_id, 'base_id': self.base_id,
# [已修改] 使用 self.base
# [新增] 公司名称
'company_name': self.base.company_name if self.base else '',
'material_name': self.base.name if self.base else '', 'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '', 'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category if self.base else '', 'category': self.base.category if self.base else '',
@ -115,7 +118,6 @@ class StockProduct(db.Model):
'quality_status': self.quality_status, 'quality_status': self.quality_status,
# [核心修改] 三个图片/链接字段全部解析为数组
'product_photo': parse_img_list(self.product_photo), 'product_photo': parse_img_list(self.product_photo),
'quality_report_link': parse_img_list(self.quality_report_link), 'quality_report_link': parse_img_list(self.quality_report_link),
'inspection_report_link': parse_img_list(self.inspection_report_link), 'inspection_report_link': parse_img_list(self.inspection_report_link),

View File

@ -3,6 +3,7 @@ from app.extensions import db
import json import json
from app.models.base import MaterialBase from app.models.base import MaterialBase
class StockSemi(db.Model): class StockSemi(db.Model):
""" """
半成品入库库存表 半成品入库库存表
@ -43,19 +44,19 @@ class StockSemi(db.Model):
quality_status = db.Column(db.String(50)) quality_status = db.Column(db.String(50))
# [修改] 质量报告 (存储 JSON 字符串: 图片列表 + 链接) # 质量报告 (存储 JSON 字符串: 图片列表 + 链接)
quality_report_link = db.Column(db.Text) quality_report_link = db.Column(db.Text)
# [新增] 到货图片 (存储 JSON 字符串) # 到货图片 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text) arrival_photo = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
remark = db.Column(db.Text) remark = db.Column(db.Text)
# [新增] 全局打印流水号 # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 [已修改] # 关系定义
base = db.relationship('MaterialBase', back_populates='stock_semis') base = db.relationship('MaterialBase', back_populates='stock_semis')
def to_dict(self): def to_dict(self):
@ -78,7 +79,9 @@ class StockSemi(db.Model):
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_id, 'base_id': self.base_id,
# [已修改] 使用 self.base
# [新增] 公司名称
'company_name': self.base.company_name if self.base else '',
'material_name': self.base.name if self.base else '', 'material_name': self.base.name if self.base else '',
'spec_model': self.base.spec_model if self.base else '', 'spec_model': self.base.spec_model if self.base else '',
'category': self.base.category if self.base else '', 'category': self.base.category if self.base else '',
@ -115,7 +118,6 @@ class StockSemi(db.Model):
'quality_status': self.quality_status, 'quality_status': self.quality_status,
# [修改] 解析 JSON 字符串为数组返回给前端
'quality_report_link': parse_img_list(self.quality_report_link), 'quality_report_link': parse_img_list(self.quality_report_link),
'arrival_photo': parse_img_list(self.arrival_photo), 'arrival_photo': parse_img_list(self.arrival_photo),

View File

@ -3,18 +3,24 @@ from app.extensions import db
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime from datetime import datetime
class SysUser(db.Model): class SysUser(db.Model):
"""
系统用户表
对应数据库: sys_user
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan)
"""
__tablename__ = 'sys_user' __tablename__ = 'sys_user'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), nullable=False) username = db.Column(db.String(100), nullable=False) # 存储 "张三/zhangsan"
# 注意:如果允许邮箱为空,建议去掉 unique=True 或者在数据库层面处理空字符串
email = db.Column(db.String(100), unique=True) email = db.Column(db.String(100), unique=True)
department = db.Column(db.String(100)) department = db.Column(db.String(100))
role = db.Column(db.String(50)) role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active') status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text) password_hash = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now) # 新增创建时间
# created_at 已在数据库脚本中移除,此处不再定义
def set_password(self, password): def set_password(self, password):
"""生成加密密码""" """生成加密密码"""
@ -25,17 +31,37 @@ class SysUser(db.Model):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
def to_dict(self): def to_dict(self):
"""序列化为字典,供接口返回使用""" """
序列化为字典
数据库存的是 '张三/zhangsan'
前端需要的是 '张三(zhangsan)'
"""
raw_name = self.username
display_name = raw_name
account_id = raw_name
# 解析存储格式: Name/ID
if '/' in raw_name:
parts = raw_name.split('/')
real_name = parts[0]
acc_id = parts[1]
# 格式化为前端展示格式: 张三(zhangsan)
display_name = f"{real_name}({acc_id})"
# 单独提取账号ID (如果前端需要单独用)
account_id = acc_id
return { return {
'id': self.id, 'id': self.id,
'username': self.username, 'username': display_name, # 列表显示: 张三(zhangsan)
'raw_username': self.username, # 原始数据
'account_id': account_id, # 纯账号ID: zhangsan
'email': self.email, 'email': self.email,
'department': self.department, 'department': self.department,
'role': self.role, 'role': self.role,
'status': self.status, 'status': self.status
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else ''
} }
class SysLog(db.Model): class SysLog(db.Model):
""" """
系统操作日志表 系统操作日志表

View File

@ -3,6 +3,7 @@ from app.models.system import SysUser
from app.extensions import db from app.extensions import db
from flask_jwt_extended import create_access_token from flask_jwt_extended import create_access_token
from app.utils.constants import UserRole from app.utils.constants import UserRole
from datetime import timedelta
class AuthService: class AuthService:
@ -12,29 +13,35 @@ class AuthService:
@staticmethod @staticmethod
def login(data): def login(data):
username = data.get('username') # 用户登录时输入的只是账号ID (例如: zhangsan)
login_input = data.get('username', '').strip()
password = data.get('password') password = data.get('password')
user_role = None user_role = None
user_id = None user_id = None
user_info = {} user_info = {}
# 1. 优先检查硬编码的超级管理员 # 1. 优先检查硬编码的超级管理员 (IRIS)
if username == AuthService.SUPER_ADMIN_USER: if login_input == AuthService.SUPER_ADMIN_USER:
if password == AuthService.SUPER_ADMIN_PASS: if password == AuthService.SUPER_ADMIN_PASS:
user_role = UserRole.SUPER_ADMIN user_role = UserRole.SUPER_ADMIN
user_id = 0 # 虚拟ID user_id = 0
user_info = { user_info = {
'username': username, 'username': '超级管理员(IRIS)',
'account_id': 'IRIS',
'role': user_role, 'role': user_role,
'department': 'System' 'department': 'System',
'status': 'active'
} }
else: else:
raise ValueError("密码错误") raise ValueError("密码错误")
# 2. 如果不是 IRIS检查数据库用户 # 2. 检查数据库用户
# 数据库存的是 "张三/zhangsan"
# 登录匹配逻辑: 查找以 "/login_input" 结尾的记录
else: else:
user = SysUser.query.filter_by(username=username).first() # 使用 like 进行后缀匹配: '%/zhangsan'
user = SysUser.query.filter(SysUser.username.like(f"%/{login_input}")).first()
if not user: if not user:
raise ValueError("用户不存在") raise ValueError("用户不存在")
@ -50,9 +57,17 @@ class AuthService:
user_info = user.to_dict() user_info = user.to_dict()
# 3. 生成 Token # 3. 生成 Token
# Token 中 identity 存数据库IDclaims 存登录账号ID
account_id = user_info.get('account_id', login_input)
access_token = create_access_token( access_token = create_access_token(
identity=user_id, identity=user_id,
additional_claims={'role': user_role, 'username': username} additional_claims={
'role': user_role,
'username': account_id, # 存纯账号ID
'display_name': user_info.get('username') # 存显示名
},
expires_delta=timedelta(days=7)
) )
return { return {
@ -63,25 +78,59 @@ class AuthService:
@staticmethod @staticmethod
def create_user(data, operator_role): def create_user(data, operator_role):
""" """
创建新用户 (仅限管理员使用) 创建新用户
data 包含: cn_name(张三), username(zhangsan), ...
""" """
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]: if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以创建新用户") raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
if SysUser.query.filter_by(username=data.get('username')).first(): cn_name = data.get('cn_name')
raise Exception("用户名已存在") pinyin_base = data.get('username') # 前端传来的基础拼音,如 zhangsan
if not cn_name or not pinyin_base:
raise Exception("姓名和账号不能为空")
role = data.get('role') role = data.get('role')
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
# 验证角色合法性
valid_roles = [
v for k, v in UserRole.__dict__.items()
if not k.startswith('__') and isinstance(v, str)
]
if role not in valid_roles: if role not in valid_roles:
raise Exception(f"角色无效,可选角色: {valid_roles}") raise Exception(f"角色无效")
if operator_role == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
raise Exception("权限不足:主管无法创建超级管理员")
email = data.get('email', '') email = data.get('email', '')
if email and SysUser.query.filter_by(email=email).first(): if email and SysUser.query.filter_by(email=email).first():
raise Exception("邮箱已被使用") raise Exception("邮箱已被使用")
# === 核心逻辑: 自动处理账号重复 (zhangsan -> zhangsan1 -> zhangsan2) ===
final_account_id = pinyin_base
counter = 1 # 如果重复从1开始累加
while True:
# 检查数据库是否存在以 "/final_account_id" 结尾的记录
existing = SysUser.query.filter(
(SysUser.username.like(f"%/{final_account_id}")) |
(SysUser.username == final_account_id)
).first()
if not existing:
break # 找到了可用的ID跳出循环
# 如果存在,使用 base + counter
final_account_id = f"{pinyin_base}{counter}"
counter += 1
# 拼接最终存储格式: 张三/zhangsan1
full_username_storage = f"{cn_name}/{final_account_id}"
new_user = SysUser( new_user = SysUser(
username=data.get('username'), username=full_username_storage, # 存组合串
email=email, email=email,
department=data.get('department', ''), department=data.get('department', ''),
role=role, role=role,
@ -92,15 +141,17 @@ class AuthService:
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
# 返回时最好把生成的ID告诉前端
return new_user.to_dict() return new_user.to_dict()
@staticmethod @staticmethod
def update_user(user_id, data, operator_role): def update_user(user_id, data, operator_role):
""" """
[新增] 更新用户信息 更新用户信息
注意: 这里暂时不允许修改用户名/账号,因为涉及 split 逻辑较复杂,且通常账号不开通后不改
""" """
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]: if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以修改用户信息") raise Exception("权限不足")
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
if not user: if not user:
@ -108,16 +159,21 @@ class AuthService:
# 1. 更新基本信息 # 1. 更新基本信息
if 'role' in data: if 'role' in data:
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')] valid_roles = [
if data['role'] not in valid_roles: v for k, v in UserRole.__dict__.items()
if not k.startswith('__') and isinstance(v, str)
]
new_role = data['role']
if new_role not in valid_roles:
raise Exception(f"角色无效") raise Exception(f"角色无效")
user.role = data['role'] if operator_role == UserRole.SUPERVISOR and new_role == UserRole.SUPER_ADMIN:
raise Exception("权限不足")
user.role = new_role
if 'department' in data: if 'department' in data:
user.department = data['department'] user.department = data['department']
if 'email' in data: if 'email' in data:
# 如果修改了邮箱,且新邮箱已被其他人使用
email = data['email'] email = data['email']
if email and email != user.email: if email and email != user.email:
existing = SysUser.query.filter_by(email=email).first() existing = SysUser.query.filter_by(email=email).first()
@ -125,7 +181,9 @@ class AuthService:
raise Exception("该邮箱已被其他用户使用") raise Exception("该邮箱已被其他用户使用")
user.email = email user.email = email
# 2. 如果提供了密码,则重置密码;否则保持原密码 if 'status' in data:
user.status = data['status']
new_password = data.get('password') new_password = data.get('password')
if new_password and str(new_password).strip(): if new_password and str(new_password).strip():
if len(new_password) < 6: if len(new_password) < 6:
@ -144,8 +202,8 @@ class AuthService:
@staticmethod @staticmethod
def delete_user(user_id, operator_role): def delete_user(user_id, operator_role):
"""删除用户""" """删除用户"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]: if operator_role != UserRole.SUPER_ADMIN:
raise Exception("权限不足") raise Exception("权限不足:只有超级管理员可以删除用户")
user = SysUser.query.get(user_id) user = SysUser.query.get(user_id)
if not user: if not user:

View File

@ -4,12 +4,16 @@ from app.extensions import db
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
# 假设您有 StockProduct 和 StockService 的模型定义 from app.models.inbound.product import StockProduct
# from app.models.inbound.product import StockProduct
# from app.models.inbound.service import StockService # from app.models.inbound.service import StockService
from sqlalchemy import or_ from sqlalchemy import or_, and_
import traceback import traceback
import json import json
import io
import datetime
# 需要 pip install openpyxl
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
class MaterialBaseService: class MaterialBaseService:
@ -33,14 +37,37 @@ class MaterialBaseService:
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.common_name.ilike(f'%{keyword}%'), MaterialBase.common_name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(f'%{keyword}%'),
# 支持搜索公司名
MaterialBase.company_name.ilike(f'%{keyword}%')
) )
).limit(20) )
# [修改1] 增加返回数量限制
# 原为 limit(20),现改为 1000确保前端能获取所有或足够多的数据
query = query.limit(1000)
# 获取查询结果对象列表
db_items = query.all()
# [修改2] 规格型号排序逻辑
# 要求:只考虑 '/' 前面的内容进行排序
# 使用 Python 的 sort 方法,提取 spec_model 中 '/' 前的部分
def get_sort_key(item):
if not item.spec_model:
return ""
# 如果包含 '/',取前半部分;否则取整个字符串
parts = item.spec_model.split('/')
return parts[0] if len(parts) > 0 else item.spec_model
# 执行排序
db_items.sort(key=get_sort_key)
results = [] results = []
for item in query.all(): for item in db_items:
results.append({ results.append({
'id': item.id, 'id': item.id, # 必须保留ID供前端逻辑使用视觉上的隐藏请在前端处理
'companyName': item.company_name,
'name': item.name, 'name': item.name,
'commonName': item.common_name, 'commonName': item.common_name,
'spec': item.spec_model, 'spec': item.spec_model,
@ -58,29 +85,21 @@ class MaterialBaseService:
def _get_stock_counts(stock_query): def _get_stock_counts(stock_query):
""" """
辅助函数:安全计算库存列表的总数量 辅助函数:安全计算库存列表的总数量
修复逻辑:优先查找 'stock_quantity' (Buy/Semi/Product表中实际使用的字段)
""" """
total_inv = 0 total_inv = 0
total_avail = 0 total_avail = 0
# 如果 stock_query 是动态加载的查询对象 (AppenderQuery),需要迭代它
# 如果是列表,直接迭代
try: try:
items = list(stock_query) # 触发查询 items = list(stock_query) # 触发查询
except: except:
items = [] items = []
for x in items: for x in items:
# 1. 获取库存数 # 1. 获取库存数 (兼容不同字段名)
# 【修复点】根据你提供的 Service 代码Buy/Semi/Product 均使用 stock_quantity q = getattr(x, 'stock_quantity', getattr(x, 'in_quantity', 0)) # 优先取库存,其次入库
# Service 使用 actual_quantity这里做兼容查找
q = getattr(x, 'stock_quantity', getattr(x, 'actual_quantity', getattr(x, 'quantity', 0)))
# 2. 获取可用数 # 2. 获取可用数
# 这里的字段名通常都是 available_quantity
a = getattr(x, 'available_quantity', q) a = getattr(x, 'available_quantity', q)
# 累加 (转 float 防止 None 或 Decimal 计算报错)
try: try:
total_inv += float(q if q is not None else 0) total_inv += float(q if q is not None else 0)
total_avail += float(a if a is not None else 0) total_avail += float(a if a is not None else 0)
@ -93,13 +112,12 @@ class MaterialBaseService:
def get_list(page, limit, filters=None): def get_list(page, limit, filters=None):
""" """
获取基础信息列表 (带分页和筛选) 获取基础信息列表 (带分页和筛选)
并聚合库存总数和可用总数
""" """
try: try:
query = MaterialBase.query query = MaterialBase.query
if filters: if filters:
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号) # 1. 关键词模糊搜索
if filters.get('keyword'): if filters.get('keyword'):
kw = f"%{filters['keyword']}%" kw = f"%{filters['keyword']}%"
query = query.filter(or_( query = query.filter(or_(
@ -109,6 +127,10 @@ class MaterialBaseService:
)) ))
# 2. 精确筛选 # 2. 精确筛选
# 公司筛选
if filters.get('company'):
query = query.filter_by(company_name=filters['company'])
if filters.get('category'): if filters.get('category'):
query = query.filter_by(category=filters['category']) query = query.filter_by(category=filters['category'])
@ -116,34 +138,23 @@ class MaterialBaseService:
query = query.filter_by(material_type=filters['type']) query = query.filter_by(material_type=filters['type'])
if filters.get('isEnabled') is not None: if filters.get('isEnabled') is not None:
# 前端传 1/0转为 Boolean
is_active = bool(int(filters['isEnabled'])) is_active = bool(int(filters['isEnabled']))
query = query.filter_by(is_enabled=is_active) query = query.filter_by(is_enabled=is_active)
# 按 ID 倒序排列 # [修改3] 默认排序方式改为按 spec_model 排序
pagination = query.order_by(MaterialBase.id.desc()).paginate(page=page, per_page=limit, error_out=False) pagination = query.order_by(MaterialBase.spec_model.asc()).paginate(page=page, per_page=limit,
error_out=False)
items_list = [] items_list = []
for item in pagination.items: for item in pagination.items:
# 获取基础字典
item_dict = item.to_dict() item_dict = item.to_dict()
# [调用修复后的辅助函数] # 聚合库存
# 1. 采购库存 (StockBuy)
buy_inv, buy_avail = MaterialBaseService._get_stock_counts(item.stock_buys) buy_inv, buy_avail = MaterialBaseService._get_stock_counts(item.stock_buys)
# 2. 半成品库存 (StockSemi)
semi_inv, semi_avail = MaterialBaseService._get_stock_counts(item.stock_semis) semi_inv, semi_avail = MaterialBaseService._get_stock_counts(item.stock_semis)
# 3. 成品库存 (StockProduct)
prod_inv, prod_avail = MaterialBaseService._get_stock_counts(item.stock_products) prod_inv, prod_avail = MaterialBaseService._get_stock_counts(item.stock_products)
# 4. 服务库存 (StockService)
# 使用 getattr 防止关联不存在时报错
serv_inv, serv_avail = MaterialBaseService._get_stock_counts(getattr(item, 'stock_services', [])) serv_inv, serv_avail = MaterialBaseService._get_stock_counts(getattr(item, 'stock_services', []))
# 合并总数
item_dict['inventoryCount'] = buy_inv + semi_inv + prod_inv + serv_inv item_dict['inventoryCount'] = buy_inv + semi_inv + prod_inv + serv_inv
item_dict['availableCount'] = buy_avail + semi_avail + prod_avail + serv_avail item_dict['availableCount'] = buy_avail + semi_avail + prod_avail + serv_avail
@ -159,37 +170,45 @@ class MaterialBaseService:
@staticmethod @staticmethod
def get_distinct_options(): def get_distinct_options():
""" """
获取所有已存在的类别类型 (去重) 获取所有已存在的类别类型、公司 (去重且排序)
用于前端下拉筛选
""" """
try: try:
# 查询所有不为空的类别并去重 # 1. 类别 (获取后在内存或前端做层级处理,这里先按字母序返回扁平列表)
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
# 查询所有不为空的类型并去重 # 对类别进行排序
sorted_categories = sorted([c[0] for c in categories])
# 2. 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .distinct().all()
sorted_types = sorted([t[0] for t in types])
# 3. 公司
companies = db.session.query(MaterialBase.company_name) \
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
sorted_companies = sorted([c[0] for c in companies])
return { return {
"categories": [c[0] for c in categories], "categories": sorted_categories,
"types": [t[0] for t in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}
@staticmethod @staticmethod
def create_material(data): def create_material(data):
"""新增基础信息""" """新增基础信息"""
try: try:
# 0. 基础校验
if not data.get('name') or not data.get('spec'): if not data.get('name') or not data.get('spec'):
raise ValueError("名称和规格型号不能为空") raise ValueError("名称和规格型号不能为空")
# 1. 查重
exist = MaterialBase.query.filter_by( exist = MaterialBase.query.filter_by(
name=data['name'], name=data['name'],
spec_model=data['spec'] spec_model=data['spec']
@ -197,8 +216,9 @@ class MaterialBaseService:
if exist: if exist:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})") raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
# 2. 创建对象 (列表转JSON字符串)
new_material = MaterialBase( new_material = MaterialBase(
# [修改] 移除了 'IRIS' 默认值
company_name=data.get('companyName'),
name=data['name'], name=data['name'],
common_name=data.get('commonName'), common_name=data.get('commonName'),
spec_model=data['spec'], spec_model=data['spec'],
@ -206,7 +226,6 @@ class MaterialBaseService:
material_type=data.get('type'), material_type=data.get('type'),
unit=data.get('unit'), unit=data.get('unit'),
visibility_level=data.get('visibilityLevel'), visibility_level=data.get('visibilityLevel'),
# 修改:将列表 dumps 为字符串
manual_link=json.dumps(data.get('generalManual', [])), manual_link=json.dumps(data.get('generalManual', [])),
product_image=json.dumps(data.get('generalImage', [])), product_image=json.dumps(data.get('generalImage', [])),
is_enabled=True if data.get('isEnabled', 1) == 1 else False is_enabled=True if data.get('isEnabled', 1) == 1 else False
@ -229,6 +248,7 @@ class MaterialBaseService:
raise ValueError("数据不存在") raise ValueError("数据不存在")
# 更新字段 # 更新字段
if 'companyName' in data: material.company_name = data['companyName']
if 'name' in data: material.name = data['name'] if 'name' in data: material.name = data['name']
if 'commonName' in data: material.common_name = data['commonName'] if 'commonName' in data: material.common_name = data['commonName']
if 'spec' in data: material.spec_model = data['spec'] if 'spec' in data: material.spec_model = data['spec']
@ -237,7 +257,6 @@ class MaterialBaseService:
if 'unit' in data: material.unit = data['unit'] if 'unit' in data: material.unit = data['unit']
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel'] if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
# 修改:将列表 dumps 为字符串
if 'generalManual' in data: if 'generalManual' in data:
material.manual_link = json.dumps(data['generalManual']) material.manual_link = json.dumps(data['generalManual'])
if 'generalImage' in data: if 'generalImage' in data:
@ -265,18 +284,16 @@ class MaterialBaseService:
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count() buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count() semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count()
# 如果需要检查成品和服务,可以解开注释 total_usage = buy_usage_count + semi_usage_count + prod_usage_count
# from app.models.inbound.product import StockProduct
# prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count()
total_usage = buy_usage_count + semi_usage_count
if total_usage > 0: if total_usage > 0:
raise ValueError( raise ValueError(
f"无法删除:该基础物料正被使用中。\n" f"无法删除:该基础物料正被使用中。\n"
f"- 采购库存记录: {buy_usage_count}\n" f"- 采购库存记录: {buy_usage_count}\n"
f"- 半成品库存记录: {semi_usage_count}\n" f"- 半成品库存记录: {semi_usage_count}\n"
f"- 成品库存记录: {prod_usage_count}\n"
f"请先清理相关库存或仅‘禁用’此条目。" f"请先清理相关库存或仅‘禁用’此条目。"
) )
@ -288,3 +305,234 @@ class MaterialBaseService:
db.session.rollback() db.session.rollback()
print(f"删除基础信息失败: {e}") print(f"删除基础信息失败: {e}")
raise e raise e
# ==============================================================================
# [核心修改] 统一资产统计导出
# ==============================================================================
@staticmethod
def export_excel(filters=None):
"""
全口径资产统计报表:
逻辑:先查库存表 (Buy, Semi, Product),关联基础信息,只导出实际存在的库存记录。
"""
try:
# 1. 构造基础信息的筛选条件 (用于过滤库存)
filter_conditions = []
if filters:
if filters.get('keyword'):
kw = f"%{filters['keyword']}%"
filter_conditions.append(or_(
MaterialBase.name.ilike(kw),
MaterialBase.common_name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw)
))
if filters.get('company'):
filter_conditions.append(MaterialBase.company_name == filters['company'])
if filters.get('category'):
filter_conditions.append(MaterialBase.category == filters['category'])
if filters.get('type'):
filter_conditions.append(MaterialBase.material_type == filters['type'])
if filters.get('isEnabled') is not None:
is_active = bool(int(filters['isEnabled']))
filter_conditions.append(MaterialBase.is_enabled == is_active)
# 2. 分别查询三个库存表,并 Join MaterialBase 进行筛选
# 2.1 采购库存 (StockBuy)
query_buy = db.session.query(StockBuy, MaterialBase).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
)
for cond in filter_conditions:
query_buy = query_buy.filter(cond)
list_buy = query_buy.all()
# 2.2 半成品库存 (StockSemi)
query_semi = db.session.query(StockSemi, MaterialBase).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
)
for cond in filter_conditions:
query_semi = query_semi.filter(cond)
list_semi = query_semi.all()
# 2.3 成品库存 (StockProduct)
query_product = db.session.query(StockProduct, MaterialBase).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
)
for cond in filter_conditions:
query_product = query_product.filter(cond)
list_product = query_product.all()
# 3. 数据整合
all_rows = []
# 处理采购件
for stock, base in list_buy:
# 价格计算
unit_price = float(stock.unit_price or 0)
tax_rate = float(stock.tax_rate or 0)
price_incl = unit_price * (1 + tax_rate / 100.0)
qty = float(stock.stock_quantity or 0)
# 计算不含税总价 = 数量 * 不含税单价
total_val_excl = qty * unit_price
# 计算含税总价 = 数量 * 含税单价
total_val_incl = qty * price_incl
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
all_rows.append({
"base": base,
"type_name": "采购件",
"ident": ident,
"loc": stock.warehouse_location,
"source": stock.supplier_name,
"date": stock.in_date,
"qty": qty,
"avail": float(stock.available_quantity or 0),
"price_excl": unit_price,
"total_val_excl": total_val_excl, # [新增]
"tax": tax_rate,
"price_incl": price_incl,
"total_val": total_val_incl
})
# 处理半成品
for stock, base in list_semi:
cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0)
qty = float(stock.stock_quantity or 0)
# 半成品不含税总价 = 数量 * 成本
total_val_excl = qty * cost
# 含税总价同上 (税率0)
total_val_incl = qty * cost
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
all_rows.append({
"base": base,
"type_name": "半成品",
"ident": ident,
"loc": stock.warehouse_location,
"source": stock.production_manager,
"date": stock.production_date,
"qty": qty,
"avail": float(stock.available_quantity or 0),
"price_excl": cost,
"total_val_excl": total_val_excl, # [新增]
"tax": 0.0,
"price_incl": cost,
"total_val": total_val_incl
})
# 处理成品
for stock, base in list_product:
cost = float(stock.raw_material_cost or 0) + float(stock.manual_cost or 0)
qty = float(stock.stock_quantity or 0)
total_val_excl = qty * cost
total_val_incl = qty * cost
ident = stock.serial_number or stock.barcode or stock.sku
all_rows.append({
"base": base,
"type_name": "成品",
"ident": ident,
"loc": stock.warehouse_location,
"source": stock.production_manager,
"date": stock.production_date,
"qty": qty,
"avail": float(stock.available_quantity or 0),
"price_excl": cost,
"total_val_excl": total_val_excl, # [新增]
"tax": 0.0,
"price_incl": cost,
"total_val": total_val_incl
})
# 4. 排序:按公司 -> 规格型号 -> 基础ID -> 批号 排序
all_rows.sort(key=lambda x: (
x['base'].company_name or "",
x['base'].spec_model or "",
x['base'].id,
x['ident'] or ""
))
# 5. 生成 Excel
wb = Workbook()
ws = wb.active
ws.title = "库存统计"
# 表头 [修改] 增加 "资产总额 (不含税)"
headers = [
"所属公司", "资产名称", "规格型号", "物料类型",
"类别一级", "类别二级", "类别三级", "类别四级", "类别五级",
"计量单位",
"库存性质", "唯一标识码 (批号/SN)", "仓库位置",
"资产来源", "入库/生产日期",
"库存数量", "可用数量",
"单价/成本 (不含税)", "资产总额 (不含税)", "税率 (%)", "单价/成本 (含税)", "资产总额 (含税)"
]
ws.append(headers)
# 样式
header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid")
border_style = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'),
bottom=Side(style='thin'))
for cell in ws[1]:
cell.font = Font(bold=True, name='微软雅黑')
cell.alignment = Alignment(horizontal='center', vertical='center')
cell.fill = header_fill
cell.border = border_style
# 写入数据
for r in all_rows:
base = r['base']
# 类别拆分
cat_parts = (base.category or "").split('/')
while len(cat_parts) < 5:
cat_parts.append("")
# 日期格式化
date_str = r['date'].strftime('%Y-%m-%d') if isinstance(r['date'], datetime.date) else ""
row_val = [
base.company_name,
base.name,
base.spec_model,
base.material_type,
cat_parts[0], cat_parts[1], cat_parts[2], cat_parts[3], cat_parts[4],
base.unit,
r['type_name'],
r['ident'],
r['loc'],
r['source'],
date_str,
r['qty'],
r['avail'],
r['price_excl'],
r['total_val_excl'], # [新增] 对应列
r['tax'],
r['price_incl'],
r['total_val']
]
ws.append(row_val)
# 列宽调整
dims = {}
for row in ws.rows:
for cell in row:
if cell.value:
dims[cell.column_letter] = max((dims.get(cell.column_letter, 0), len(str(cell.value))))
for col, value in dims.items():
ws.column_dimensions[col].width = min(value + 2, 30)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output
except Exception as e:
traceback.print_exc()
raise e

View File

@ -1,3 +1,4 @@
# inventory-backend/app/services/inbound/buy_service.py
from app.extensions import db from app.extensions import db
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.base import MaterialBase from app.models.base import MaterialBase
@ -47,7 +48,8 @@ class BuyInboundService:
query = query.filter(and_( query = query.filter(and_(
or_( or_(
MaterialBase.name.ilike(k_str), MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str) MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str) # 支持搜公司
) )
)) ))
@ -58,6 +60,7 @@ class BuyInboundService:
for item in pagination.items: for item in pagination.items:
items.append({ items.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增]
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -114,6 +117,7 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0) u_price = float(data.get('unit_price') or 0)
tax_rate = float(data.get('tax_rate') or 0) # [新增]
try: try:
seq_sql = text("SELECT nextval('global_print_seq')") seq_sql = text("SELECT nextval('global_print_seq')")
@ -131,8 +135,14 @@ class BuyInboundService:
status=data.get('status', '在库'), in_quantity=in_qty, stock_quantity=in_qty, available_quantity=in_qty, status=data.get('status', '在库'), in_quantity=in_qty, stock_quantity=in_qty, available_quantity=in_qty,
inspection_status=data.get('inspection_status', '未检'), inspection_status=data.get('inspection_status', '未检'),
warehouse_location=data.get('warehouse_location'), warehouse_location=data.get('warehouse_location'),
unit_price=u_price, total_price=in_qty * u_price, currency=data.get('currency', 'CNY'),
# 价格信息
unit_price=u_price,
tax_rate=tax_rate, # [新增]
total_price=in_qty * u_price,
currency=data.get('currency', 'CNY'),
exchange_rate=data.get('exchange_rate', 1.0), exchange_rate=data.get('exchange_rate', 1.0),
supplier_name=data.get('supplier_name'), buyer_name=data.get('purchaser'), supplier_name=data.get('supplier_name'), buyer_name=data.get('purchaser'),
buyer_email=data.get('purchaser_email'), buyer_email=data.get('purchaser_email'),
original_link=data.get('source_link'), detail_link=data.get('detail_link'), original_link=data.get('source_link'), detail_link=data.get('detail_link'),
@ -172,13 +182,18 @@ class BuyInboundService:
if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo']) if 'arrival_photo' in data: stock.arrival_photo = json.dumps(data['arrival_photo'])
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report']) if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
# [新增] 更新税率
if 'tax_rate' in data: stock.tax_rate = float(data['tax_rate'])
if 'in_quantity' in data: if 'in_quantity' in data:
diff = float(data['in_quantity']) - float(stock.in_quantity) diff = float(data['in_quantity']) - float(stock.in_quantity)
if diff != 0: if diff != 0:
stock.in_quantity = float(data['in_quantity']) stock.in_quantity = float(data['in_quantity'])
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff
if 'unit_price' in data: stock.unit_price = float(data['unit_price']) if 'unit_price' in data: stock.unit_price = float(data['unit_price'])
stock.total_price = float(stock.in_quantity) * float(stock.unit_price) stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
db.session.commit() db.session.commit()
return stock return stock
@ -205,7 +220,7 @@ class BuyInboundService:
# 5. 获取列表 # 5. 获取列表
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
try: try:
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id) query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
@ -219,18 +234,23 @@ class BuyInboundService:
StockBuy.serial_number.ilike(k_str), StockBuy.serial_number.ilike(k_str),
StockBuy.supplier_name.ilike(k_str), StockBuy.supplier_name.ilike(k_str),
StockBuy.buyer_name.ilike(k_str), StockBuy.buyer_name.ilike(k_str),
MaterialBase.name.ilike(k_str), # 名称 MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str), # 规格 MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str), # 关键词也支持搜公司
] ]
query = query.filter(or_(*conditions)) query = query.filter(or_(*conditions))
# 2. 类别独立搜索 # 2. 类别独立搜索
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 下拉框通常是精确匹配 query = query.filter(MaterialBase.category == category.strip())
# 3. 类型独立搜索 # 3. 类型独立搜索
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) # 精确匹配 query = query.filter(MaterialBase.material_type == material_type.strip())
# 3.1 公司独立搜索 [新增]
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
# 4. 状态筛选 # 4. 状态筛选
if not statuses: statuses = ['在库', '借库'] if not statuses: statuses = ['在库', '借库']
@ -242,52 +262,44 @@ class BuyInboundService:
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False) pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
items = [] items = []
for item in pagination.items: for item in pagination.items:
items.append({ items.append(item.to_dict()) # 直接使用 model 的 to_dict
'id': item.id, 'base_id': item.base_id, 'material_name': item.base.name if item.base else '',
'spec_model': item.base.spec_model if item.base else '',
'category': item.base.category if item.base else '', 'unit': item.base.unit if item.base else '',
'material_type': item.base.material_type if item.base else '', 'sku': item.sku,
'inbound_date': str(item.in_date)[:10] if item.in_date else '', 'barcode': item.barcode,
'serial_number': item.serial_number, 'batch_number': item.batch_number, 'status': item.status,
'inspection_status': item.inspection_status, 'qty_inbound': float(item.in_quantity or 0),
'qty_stock': float(item.stock_quantity or 0), 'qty_available': float(item.available_quantity or 0),
'warehouse_loc': item.warehouse_location, 'unit_price': float(item.unit_price or 0),
'total_price': float(item.total_price or 0), 'currency': item.currency,
'exchange_rate': float(item.exchange_rate or 1), 'supplier_name': item.supplier_name,
'purchaser': item.buyer_name, 'purchaser_email': item.buyer_email,
'source_link': item.original_link, 'detail_link': item.detail_link,
'arrival_photo': json.loads(item.arrival_photo) if item.arrival_photo else [],
'inspection_report': json.loads(item.inspection_report) if item.inspection_report else [],
'global_print_id': item.global_print_id
})
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
return {"total": 0, "items": []} return {"total": 0, "items": []}
# ============================================================ # ============================================================
# 6. [新增] 获取筛选选项(类别、类型 # 6. 获取筛选选项(类别、类型、公司)并排序
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
# 获取所有非空的类别 # 类别
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 获取所有非空的类型 # 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .distinct().all()
sorted_types = sorted([r[0] for r in types])
# [新增] 公司
companies = db.session.query(MaterialBase.company_name) \
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
sorted_companies = sorted([r[0] for r in companies])
return { return {
"categories": [r[0] for r in categories], "categories": sorted_categories,
"types": [r[0] for r in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}
# 7-10 建议类接口保持不变 # 7-10 建议类接口保持不变
@staticmethod @staticmethod

View File

@ -33,10 +33,12 @@ class ProductInboundService:
try: try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword: if keyword:
kw = f'%{keyword}%'
query = query.filter( query = query.filter(
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) # [新增]
) )
) )
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
@ -44,6 +46,7 @@ class ProductInboundService:
for item in query.all(): for item in query.all():
results.append({ results.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增]
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -57,13 +60,12 @@ class ProductInboundService:
return [] return []
# ============================================================ # ============================================================
# 1.5 [新增] BOM 搜索逻辑 # 1.5 BOM 搜索逻辑
# ============================================================ # ============================================================
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword):
from app.models.bom import BomTable from app.models.bom import BomTable
try: try:
# 关联查询BOM表 + 父件基础信息表
query = db.session.query( query = db.session.query(
BomTable.bom_no, BomTable.bom_no,
BomTable.version, BomTable.version,
@ -71,13 +73,11 @@ class ProductInboundService:
MaterialBase.spec_model.label('parent_spec') MaterialBase.spec_model.label('parent_spec')
).join(MaterialBase, BomTable.parent_id == MaterialBase.id) ).join(MaterialBase, BomTable.parent_id == MaterialBase.id)
# 只查询启用的BOM
if hasattr(BomTable, 'is_enabled'): if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
# 支持搜索BOM编号、父件名称、父件规格
query = query.filter( query = query.filter(
or_( or_(
BomTable.bom_no.ilike(kw), BomTable.bom_no.ilike(kw),
@ -86,7 +86,6 @@ class ProductInboundService:
) )
) )
# 去重并限制数量
results = query.distinct().limit(20).all() results = query.distinct().limit(20).all()
return [{ return [{
@ -283,23 +282,30 @@ class ProductInboundService:
# 6. 获取列表 # 6. 获取列表
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
try: try:
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id) query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
if keyword: if keyword:
kw = f'%{keyword}%'
query = query.filter(or_( query = query.filter(or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(f'%{keyword}%'), MaterialBase.spec_model.ilike(kw),
StockProduct.serial_number.ilike(f'%{keyword}%'), MaterialBase.company_name.ilike(kw), # [新增]
StockProduct.work_order_code.ilike(f'%{keyword}%'), StockProduct.serial_number.ilike(kw),
StockProduct.order_id.ilike(f'%{keyword}%'), StockProduct.work_order_code.ilike(kw),
StockProduct.sku.ilike(f'%{keyword}%') StockProduct.order_id.ilike(kw),
StockProduct.sku.ilike(kw)
)) ))
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) query = query.filter(MaterialBase.category == category.strip())
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增]
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
if not statuses: if not statuses:
statuses = ['在库', '借库'] statuses = ['在库', '借库']
if '已出库' in statuses: if '已出库' in statuses:
@ -324,23 +330,7 @@ class ProductInboundService:
items = [] items = []
for item in current_items: for item in current_items:
d = item.to_dict() items.append(item.to_dict()) # 使用 Model to_dict
date_display = ''
if item.production_date:
try:
date_display = item.production_date.strftime('%Y-%m-%d')
except:
date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available']
d['product_photo'] = parse_img(item.product_photo)
d['quality_report_link'] = parse_img(item.quality_report_link)
d['inspection_report_link'] = parse_img(item.inspection_report_link)
d['global_print_id'] = item.global_print_id
items.append(d)
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except: except:
traceback.print_exc() traceback.print_exc()
@ -368,21 +358,37 @@ class ProductInboundService:
except Exception: except Exception:
return [] return []
# ============================================================
# 7. 获取筛选项
# ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 类别
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .distinct().all()
sorted_types = sorted([r[0] for r in types])
# [新增] 公司
companies = db.session.query(MaterialBase.company_name) \
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
sorted_companies = sorted([r[0] for r in companies])
return { return {
"categories": [r[0] for r in categories], "categories": sorted_categories,
"types": [r[0] for r in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}

View File

@ -43,10 +43,12 @@ class SemiInboundService:
try: try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword: if keyword:
kw = f'%{keyword}%'
query = query.filter( query = query.filter(
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw) # [新增] 支持搜公司
) )
) )
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
@ -54,6 +56,7 @@ class SemiInboundService:
for item in query.all(): for item in query.all():
results.append({ results.append({
'id': item.id, 'id': item.id,
'company_name': item.company_name, # [新增]
'name': item.name, 'name': item.name,
'spec': item.spec_model, 'spec': item.spec_model,
'category': item.category, 'category': item.category,
@ -67,13 +70,12 @@ class SemiInboundService:
return [] return []
# ============================================================ # ============================================================
# 1.5 [新增] BOM 搜索逻辑 # 1.5 BOM 搜索逻辑
# ============================================================ # ============================================================
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword):
from app.models.bom import BomTable from app.models.bom import BomTable
try: try:
# 关联查询BOM表 + 父件基础信息表
query = db.session.query( query = db.session.query(
BomTable.bom_no, BomTable.bom_no,
BomTable.version, BomTable.version,
@ -81,13 +83,11 @@ class SemiInboundService:
MaterialBase.spec_model.label('parent_spec') MaterialBase.spec_model.label('parent_spec')
).join(MaterialBase, BomTable.parent_id == MaterialBase.id) ).join(MaterialBase, BomTable.parent_id == MaterialBase.id)
# 只查询启用的BOM
if hasattr(BomTable, 'is_enabled'): if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
# 支持搜索BOM编号、父件名称、父件规格
query = query.filter( query = query.filter(
or_( or_(
BomTable.bom_no.ilike(kw), BomTable.bom_no.ilike(kw),
@ -96,7 +96,6 @@ class SemiInboundService:
) )
) )
# 去重并限制数量
results = query.distinct().limit(20).all() results = query.distinct().limit(20).all()
return [{ return [{
@ -367,7 +366,7 @@ class SemiInboundService:
# 6. 获取列表 # 6. 获取列表
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None): def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
try: try:
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id) query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
@ -377,6 +376,7 @@ class SemiInboundService:
or_( or_(
MaterialBase.name.ilike(kw), MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw), MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw), # [新增]
StockSemi.batch_number.ilike(kw), StockSemi.batch_number.ilike(kw),
StockSemi.serial_number.ilike(kw), StockSemi.serial_number.ilike(kw),
StockSemi.sku.ilike(kw), StockSemi.sku.ilike(kw),
@ -388,6 +388,11 @@ class SemiInboundService:
query = query.filter(MaterialBase.category == category.strip()) query = query.filter(MaterialBase.category == category.strip())
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())
# [新增] 公司筛选
if company and company.strip():
query = query.filter(MaterialBase.company_name == company.strip())
if not statuses: if not statuses:
statuses = ['在库', '借库'] statuses = ['在库', '借库']
if '已出库' in statuses: if '已出库' in statuses:
@ -412,22 +417,7 @@ class SemiInboundService:
items = [] items = []
for item in current_items: for item in current_items:
d = item.to_dict() items.append(item.to_dict()) # 直接使用 Model 的 to_dict (已包含 company_name)
date_display = ''
if item.production_date:
try:
date_display = item.production_date.strftime('%Y-%m-%d')
except:
date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available']
d['arrival_photo'] = parse_img(item.arrival_photo)
d['quality_report_link'] = parse_img(item.quality_report_link)
d['global_print_id'] = item.global_print_id
items.append(d)
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except Exception as e: except Exception as e:
print(f"List Error: {e}") print(f"List Error: {e}")
@ -456,21 +446,37 @@ class SemiInboundService:
except Exception: except Exception:
return [] return []
# ============================================================
# 7. 获取筛选项 (排序)
# ============================================================
@staticmethod @staticmethod
def get_filter_options(): def get_filter_options():
try: try:
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 类别
categories = db.session.query(MaterialBase.category) \ categories = db.session.query(MaterialBase.category) \
.filter(MaterialBase.category != None, MaterialBase.category != '') \ .filter(MaterialBase.category != None, MaterialBase.category != '') \
.distinct().all() .distinct().all()
sorted_categories = sorted([r[0] for r in categories])
# 类型
types = db.session.query(MaterialBase.material_type) \ types = db.session.query(MaterialBase.material_type) \
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \ .filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
.distinct().all() .distinct().all()
sorted_types = sorted([r[0] for r in types])
# [新增] 公司
companies = db.session.query(MaterialBase.company_name) \
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
.distinct().all()
sorted_companies = sorted([r[0] for r in companies])
return { return {
"categories": [r[0] for r in categories], "categories": sorted_categories,
"types": [r[0] for r in types] "types": sorted_types,
"companies": sorted_companies
} }
except Exception: except Exception:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": []} return {"categories": [], "types": [], "companies": []}

View File

@ -1,16 +1,20 @@
# app/utils/constants.py # app/utils/constants.py
class UserRole: class UserRole:
SUPER_ADMIN = 'super_admin' # 超级管理员 (IRIS) """
SUPERVISOR = 'supervisor' # 主管 用户角色定义
FINANCE = 'finance' # 财务 """
WAREHOUSE_MGR = 'warehouse_manager' # 库管 SUPER_ADMIN = 'SUPER_ADMIN' # 超级管理员 (IRIS)
INBOUND = 'inbound' # 入库员 SUPERVISOR = 'SUPERVISOR' # 主管
OUTBOUND = 'outbound' # 出库员 FINANCE = 'FINANCE' # 财务
PURCHASER = 'purchaser' # 采购员 WAREHOUSE_MGR = 'WAREHOUSE_MGR' # 库管
SALES = 'sales' # 销售 INBOUND = 'INBOUND' # 入库员
OUTBOUND = 'OUTBOUND' # 出库员
PURCHASER = 'PURCHASER' # 采购员
SALES = 'SALES' # 销售
# 角色中文映射(用于前端展示或日志) # 角色中文映射(用于前端展示或日志)
# 注意:这个字典在 auth_service 遍历时需要被过滤掉
ROLE_MAP = { ROLE_MAP = {
SUPER_ADMIN: '超级管理员', SUPER_ADMIN: '超级管理员',
SUPERVISOR: '主管', SUPERVISOR: '主管',

View File

@ -14,3 +14,5 @@ python-barcode>=0.14.0
qrcode[pil]>=7.4.2 qrcode[pil]>=7.4.2
# [新增] 必须添加,用于处理 token 登录 # [新增] 必须添加,用于处理 token 登录
Flask-JWT-Extended==4.6.0 Flask-JWT-Extended==4.6.0
# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错)
openpyxl>=3.1.2

View File

@ -17,6 +17,7 @@
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2", "jspdf-autotable": "^3.8.2",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinyin-pro": "^3.19.0",
"sass": "^1.97.3", "sass": "^1.97.3",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",

View File

@ -82,7 +82,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本: 1.1 Beta (测试) 当前版本: 1.3 Beta (2.25权限管理)
</span> </span>
</footer> </footer>
</div> </div>

View File

@ -9,9 +9,7 @@ export function listMaterialBase(params: any) {
}) })
} }
// ========================================== // 1.1 获取选项
// 1.1 获取基础信息筛选选项 (所有类别/类型) [新增]
// ==========================================
export function getMaterialBaseOptions() { export function getMaterialBaseOptions() {
return request({ return request({
url: '/inbound/base/options', url: '/inbound/base/options',
@ -19,7 +17,17 @@ export function getMaterialBaseOptions() {
}) })
} }
// 2. 新增基础信息 // 1.2 [新增] 导出全口径资产统计表
export function exportAssetStatistics(params: any) {
return request({
url: '/inbound/base/export',
method: 'get',
params,
responseType: 'blob' // 关键:必须声明为 blob 处理文件流
})
}
// 2. 新增
export function addMaterialBase(data: any) { export function addMaterialBase(data: any) {
return request({ return request({
url: '/inbound/base/', url: '/inbound/base/',
@ -28,7 +36,7 @@ export function addMaterialBase(data: any) {
}) })
} }
// 3. 修改基础信息 (包含状态启用/禁用) // 3. 修改
export function updateMaterialBase(data: any) { export function updateMaterialBase(data: any) {
return request({ return request({
url: `/inbound/base/${data.id}`, url: `/inbound/base/${data.id}`,
@ -37,18 +45,10 @@ export function updateMaterialBase(data: any) {
}) })
} }
// 4. 删除基础信息 // 4. 删除
export function delMaterialBase(id: number) { export function delMaterialBase(id: number) {
return request({ return request({
url: `/inbound/base/${id}`, url: `/inbound/base/${id}`,
method: 'delete' method: 'delete'
}) })
} }
// 5. 获取详情 (可选,用于编辑回显)
export function getMaterialBase(id: number) {
return request({
url: `/inbound/base/${id}`,
method: 'get'
})
}

View File

@ -4,6 +4,16 @@ import Layout from '@/layout/index.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import BomManage from '@/views/bom/BomManage.vue' import BomManage from '@/views/bom/BomManage.vue'
// [新增] 扩展 RouteMeta 类型定义,防止 TS 报错
declare module 'vue-router' {
interface RouteMeta {
title?: string
icon?: string
hidden?: boolean
roles?: string[] // 允许的角色列表
}
}
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
// 1. 登录页 // 1. 登录页
{ {
@ -169,17 +179,25 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: '/system', path: '/system',
component: Layout, component: Layout,
// [修复] 添加 redirect点击父菜单时跳转到子页面
redirect: '/system/user-create',
meta: { meta: {
title: '系统管理', title: '系统管理',
icon: 'Setting', icon: 'Setting',
roles: ['super_admin', 'supervisor'] // [修复] 使用大写角色名,匹配后端常量
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}, },
children: [ children: [
{ {
path: 'user-create', path: 'user-create',
name: 'UserCreate', name: 'UserCreate',
component: () => import('@/views/system/UserCreate.vue'), component: () => import('@/views/system/UserCreate.vue'),
meta: { title: '账号开通', icon: 'User' } meta: {
title: '账号开通',
icon: 'User',
// 子路由也建议加上权限限制
roles: ['SUPER_ADMIN', 'SUPERVISOR']
}
} }
] ]
}, },
@ -204,7 +222,16 @@ router.beforeEach((to, from, next) => {
const userStore = useUserStore() const userStore = useUserStore()
const token = userStore.token || localStorage.getItem('token') const token = userStore.token || localStorage.getItem('token')
const userRole = userStore.role || localStorage.getItem('role') || 'user'
// [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效
// 注意Store 中存储的可能是 user.role 或者直接是 role根据你之前的 store 结构适配
const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user'
const userRole = String(rawRole).toUpperCase()
// 调试日志:如果跳转有问题,请按 F12 查看控制台输出
if (to.path.includes('/system')) {
console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`)
}
if (to.path === '/login') { if (to.path === '/login') {
if (token) { if (token) {
@ -220,10 +247,13 @@ router.beforeEach((to, from, next) => {
return return
} }
// 权限检查逻辑
if (to.meta.roles && Array.isArray(to.meta.roles)) { if (to.meta.roles && Array.isArray(to.meta.roles)) {
// [修复] to.meta.roles 里已经是大写了userRole 也转大写了,现在可以安全比对
if (to.meta.roles.includes(userRole)) { if (to.meta.roles.includes(userRole)) {
next() next()
} else { } else {
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles}`)
next('/dashboard') next('/dashboard')
} }
} else { } else {

View File

@ -11,6 +11,18 @@
@input="handleInputSearch" @input="handleInputSearch"
/> />
<el-select
v-model="queryParams.company"
placeholder="所属公司"
clearable
filterable
default-first-option
style="width: 120px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.category" v-model="queryParams.category"
placeholder="类别" placeholder="类别"
@ -18,8 +30,9 @@
filterable filterable
allow-create allow-create
default-first-option default-first-option
style="width: 140px; margin-right: 10px;" style="width: 240px; margin-right: 10px;"
@change="handleQuery" @change="handleQuery"
popper-class="long-dropdown"
> >
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
@ -33,6 +46,7 @@
default-first-option default-first-option
style="width: 140px; margin-right: 10px;" style="width: 140px; margin-right: 10px;"
@change="handleQuery" @change="handleQuery"
popper-class="long-dropdown"
> >
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
@ -53,6 +67,10 @@
</div> </div>
<div class="right-toolbar"> <div class="right-toolbar">
<el-button type="success" plain @click="handleExport" :loading="exportLoading" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计
</el-button>
<el-button type="primary" @click="handleAdd" style="margin-right: 10px"> <el-button type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增 <el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button> </el-button>
@ -81,6 +99,7 @@
列展示设置 列展示设置
</div> </div>
<el-checkbox v-model="columns.id.visible" label="ID" /> <el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-model="columns.name.visible" label="名称" /> <el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" /> <el-checkbox v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.category.visible" label="类别" /> <el-checkbox v-model="columns.category.visible" label="类别" />
@ -105,6 +124,13 @@
style="width: 100%; margin-top: 15px" style="width: 100%; margin-top: 15px"
> >
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" /> <el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip>
<template #default="scope">
<span>{{ scope.row.companyName || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip /> <el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip> <el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip>
@ -114,7 +140,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="120" align="center" show-overflow-tooltip> <el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="140" show-overflow-tooltip>
<template #default="scope">{{ scope.row.category || '-' }}</template> <template #default="scope">{{ scope.row.category || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip> <el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
@ -196,16 +222,16 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="margin-top: 20px; text-align: right;"> <div class="pagination-container">
<el-pagination <el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
v-model:current-page="queryParams.pageNum" v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize" v-model:page-size="queryParams.pageSize"
:page-sizes="[100, 200, 500, 1000]" :page-sizes="[10, 20, 50, 100]"
@size-change="getList" :background="true"
@current-change="getList" layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handlePageSizeChange"
@current-change="handlePageCurrentChange"
/> />
</div> </div>
@ -233,16 +259,44 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="类别" prop="category"> <el-form-item label="所属公司" prop="companyName">
<el-autocomplete <el-autocomplete
v-model="form.category" v-model="form.companyName"
:fetch-suggestions="querySearchCategory" :fetch-suggestions="querySearchCompany"
placeholder="输入或选择" placeholder="输入公司名称"
clearable clearable
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="类别" prop="category">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
v-model="tempCategoryPrefix"
:options="categoryTreeOptions"
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
placeholder="选择前缀层级"
filterable
clearable
style="width: 50%;"
/>
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
<el-input
v-model="tempCategorySuffix"
placeholder="填写具体名称"
clearable
style="width: 50%;"
/>
</div>
<div style="font-size: 12px; color: #E6A23C; margin-top: 4px; line-height: 1.2;">
* 必须构成4层结构
</div>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="类型" prop="type"> <el-form-item label="类型" prop="type">
<el-autocomplete <el-autocomplete
@ -254,25 +308,26 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="规格型号" prop="spec"> <el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="请输入规格型号" /> <el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="计量单位" prop="unit"> <el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" placeholder=": , , " /> <el-input v-model="form.unit" placeholder=": , , " />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> <el-col :span="12">
<el-form-item label="可见等级" prop="visibilityLevel"> <el-form-item label="可见等级" prop="visibilityLevel">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" /> <el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0为最低9为最高)</span> <span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
</el-form-item> </el-form-item>
</el-col>
</el-row>
<el-form-item label="产品图" prop="generalImage"> <el-form-item label="产品图" prop="generalImage">
<div class="upload-container"> <div class="upload-container">
@ -362,8 +417,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'; import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Picture, Document, Refresh, Setting, Rank, Camera, Link } from '@element-plus/icons-vue'; import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download } from '@element-plus/icons-vue';
// 修复:引入 ElLoading
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'; import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
@ -372,7 +426,8 @@ import {
addMaterialBase, addMaterialBase,
updateMaterialBase, updateMaterialBase,
delMaterialBase, delMaterialBase,
getMaterialBaseOptions // 新增引入 getMaterialBaseOptions,
exportAssetStatistics // 导入导出API
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'; import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
@ -380,6 +435,7 @@ import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
// --- 类型定义 --- // --- 类型定义 ---
interface MaterialBaseVO { interface MaterialBaseVO {
id: number; id: number;
companyName: string;
name: string; name: string;
commonName?: string; commonName?: string;
category: string; category: string;
@ -391,7 +447,6 @@ interface MaterialBaseVO {
generalImage: string[]; generalImage: string[];
isEnabled: number; isEnabled: number;
statusLoading?: boolean; statusLoading?: boolean;
// 新增字段
inventoryCount?: number; inventoryCount?: number;
availableCount?: number; availableCount?: number;
} }
@ -402,11 +457,19 @@ interface QueryParams {
keyword: string; keyword: string;
category: string; category: string;
type: string; type: string;
company: string;
isEnabled?: number; isEnabled?: number;
} }
interface CascaderOption {
value: string;
label: string;
children?: CascaderOption[];
}
// --- 响应式数据 --- // --- 响应式数据 ---
const loading = ref(false); const loading = ref(false);
const exportLoading = ref(false); // 导出加载状态
const total = ref(0); const total = ref(0);
const tableData = ref<MaterialBaseVO[]>([]); const tableData = ref<MaterialBaseVO[]>([]);
const submitLoading = ref(false); const submitLoading = ref(false);
@ -424,29 +487,35 @@ const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage'); const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
const columns = reactive({ const columns = reactive({
id: { visible: true }, id: { visible: false },
companyName: { visible: true },
name: { visible: true }, name: { visible: true },
commonName: { visible: true }, commonName: { visible: true },
category: { visible: true }, category: { visible: true },
type: { visible: true }, type: { visible: true },
spec: { visible: true }, spec: { visible: true },
unit: { visible: true }, unit: { visible: true },
// visibilityLevel: { visible: false }, // 不再使用 inventory: { visible: true },
inventory: { visible: true }, // 新增 available: { visible: true },
available: { visible: true }, // 新增
files: { visible: true }, files: { visible: true },
isEnabled: { visible: true } isEnabled: { visible: true }
}); });
const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]); const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]); const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
const queryParams = reactive<QueryParams>({ const queryParams = reactive<QueryParams>({
pageNum: 1, pageNum: 1,
pageSize: 100, // 修改默认显示条数为 100 pageSize: 100,
keyword: '', keyword: '',
category: '', category: '',
type: '', type: '',
company: '',
isEnabled: undefined isEnabled: undefined
}); });
@ -460,6 +529,7 @@ const formRef = ref<FormInstance>();
const initForm = { const initForm = {
id: undefined, id: undefined,
companyName: '',
name: '', name: '',
commonName: '', commonName: '',
category: '', category: '',
@ -474,9 +544,33 @@ const initForm = {
const form = ref({...initForm}); const form = ref({...initForm});
const validateCategoryLevel = (rule: any, value: any, callback: any) => {
const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim();
if (!prefixStr && !suffixStr) {
callback(new Error('请填写或选择类别'));
return;
}
let fullPath = '';
if (prefixStr && suffixStr) fullPath = prefixStr + '/' + suffixStr;
else if (prefixStr) fullPath = prefixStr;
else fullPath = suffixStr;
const levels = fullPath.split('/').filter(p => p.trim() !== '').length;
if (levels !== 4) {
callback(new Error(`必须严格满足4层结构当前为 ${levels} 层`));
} else {
callback();
}
};
const rules = reactive<FormRules>({ const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }], name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
category: [{ required: true, message: '请选择或输入类别', trigger: 'change' }], companyName: [{ required: true, message: '请输入公司名称', trigger: 'change' }],
category: [{ required: true, validator: validateCategoryLevel, trigger: 'change' }],
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }], type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }], spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }] unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
@ -484,22 +578,46 @@ const rules = reactive<FormRules>({
// --- 业务逻辑方法 --- // --- 业务逻辑方法 ---
// 获取所有选项(不再依赖当前页数据) const buildCategoryTree = (categories: string[]): CascaderOption[] => {
const root: CascaderOption[] = [];
categories.forEach(cat => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children;
}
});
});
return root;
};
const getOptionsList = () => { const getOptionsList = () => {
getMaterialBaseOptions().then((res: any) => { getMaterialBaseOptions().then((res: any) => {
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories || []; categoryOptions.value = res.data.categories || [];
typeOptions.value = res.data.types || []; typeOptions.value = res.data.types || [];
companyOptions.value = res.data.companies || [];
categoryTreeOptions.value = buildCategoryTree(categoryOptions.value);
} }
}).catch(err => { }).catch(err => {
console.error("获取筛选项失败", err); console.error("获取筛选项失败", err);
}); });
}; };
const querySearchCategory = (queryString: string, cb: any) => { const querySearchCompany = (queryString: string, cb: any) => {
const results = queryString const results = queryString
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase())) ? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: categoryOptions.value; : companyOptions.value;
const formattedResults = results.map(item => ({ value: item })); const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults); cb(formattedResults);
}; };
@ -519,7 +637,6 @@ const getList = () => {
if (response && response.data) { if (response && response.data) {
tableData.value = response.data.items; tableData.value = response.data.items;
total.value = response.data.total; total.value = response.data.total;
// 移除 extractDynamicOptions 调用
} else { } else {
tableData.value = []; tableData.value = [];
total.value = 0; total.value = 0;
@ -534,7 +651,51 @@ const getList = () => {
}); });
}; };
let searchTimer: ReturnType<typeof setTimeout> | null = null; // [修改] 导出处理函数:修正文件名格式
const handleExport = () => {
exportLoading.value = true;
const params = {
keyword: queryParams.keyword,
company: queryParams.company,
category: queryParams.category,
type: queryParams.type,
isEnabled: queryParams.isEnabled
};
exportAssetStatistics(params)
.then((response: any) => {
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 构造文件名库存统计_YYYYMMDD_HHMMSS
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
const second = String(now.getSeconds()).padStart(2, '0');
const filename = `库存统计_${year}${month}${day}_${hour}${minute}${second}.xlsx`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
})
.catch((err) => {
console.error("导出失败", err);
ElMessage.error('导出失败');
})
.finally(() => {
exportLoading.value = false;
});
};
let searchTimer: any = null;
const handleInputSearch = () => { const handleInputSearch = () => {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
@ -552,6 +713,7 @@ const resetQuery = () => {
queryParams.keyword = ''; queryParams.keyword = '';
queryParams.category = ''; queryParams.category = '';
queryParams.type = ''; queryParams.type = '';
queryParams.company = '';
queryParams.isEnabled = undefined; queryParams.isEnabled = undefined;
handleQuery(); handleQuery();
}; };
@ -560,6 +722,17 @@ const handleSizeChange = (command: 'large' | 'default' | 'small') => {
tableSize.value = command; tableSize.value = command;
}; };
const handlePageSizeChange = (val: number) => {
queryParams.pageSize = val;
queryParams.pageNum = 1;
getList();
};
const handlePageCurrentChange = (val: number) => {
queryParams.pageNum = val;
getList();
};
const handleAdd = () => { const handleAdd = () => {
resetForm(); resetForm();
dialog.title = '新增基础信息'; dialog.title = '新增基础信息';
@ -572,17 +745,28 @@ const handleEdit = (row: MaterialBaseVO) => {
dialog.visible = true; dialog.visible = true;
nextTick(() => { nextTick(() => {
// 基础字段赋值 - 深拷贝防止引用 const data = JSON.parse(JSON.stringify(row));
Object.assign(form.value, JSON.parse(JSON.stringify(row))); Object.assign(form.value, data);
if (data.category) {
const parts = data.category.split('/');
if (parts.length > 0) {
tempCategorySuffix.value = parts.pop() || '';
tempCategoryPrefix.value = parts;
} else {
tempCategoryPrefix.value = [];
tempCategorySuffix.value = data.category;
}
} else {
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
}
// 初始化文件列表
const images = row.generalImage || []; const images = row.generalImage || [];
const manuals = row.generalManual || []; const manuals = row.generalManual || [];
// 分离图片文件和外部链接
const imgFiles = images.filter(u => !isExternalLink(u)); const imgFiles = images.filter(u => !isExternalLink(u));
const imgLinks = images.filter(u => isExternalLink(u)); const imgLinks = images.filter(u => isExternalLink(u));
const manualFiles = manuals.filter(u => !isExternalLink(u)); const manualFiles = manuals.filter(u => !isExternalLink(u));
const manualLinks = manuals.filter(u => isExternalLink(u)); const manualLinks = manuals.filter(u => isExternalLink(u));
@ -596,12 +780,12 @@ const handleEdit = (row: MaterialBaseVO) => {
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => { const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try { try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name }); const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name && item.id !== form.value.id)) { if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name && item.id !== form.value.id)) {
ElMessage.error(`已存在名称为 "${name}" 的基础信息!`); ElMessage.error(`已存在名称为 "${name}" 的基础信息!`);
return true; return true;
} }
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec }); const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec, category: '', type: '', company: '' });
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec && item.id !== form.value.id)) { if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec && item.id !== form.value.id)) {
ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`); ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`);
return true; return true;
@ -619,23 +803,27 @@ const submitForm = async () => {
if (valid) { if (valid) {
submitLoading.value = true; submitLoading.value = true;
try { try {
// 重复校验
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec); const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) { if (isDuplicate) {
submitLoading.value = false; submitLoading.value = false;
return; return;
} }
// 整理文件数据:本地上传的路径已经在 form.value 中
// 我们需要重新合并:已有的非外链 + 新的输入框外链
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item)); const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item));
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value); if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value);
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item)); const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item));
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value); if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value);
const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim();
let fullCategory = '';
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
else fullCategory = prefixStr || suffixStr;
const payload = { const payload = {
...form.value, ...form.value,
category: fullCategory,
generalImage: finalImageList, generalImage: finalImageList,
generalManual: finalManualList generalManual: finalManualList
}; };
@ -647,7 +835,6 @@ const submitForm = async () => {
ElMessage.success(`${actionText}成功`); ElMessage.success(`${actionText}成功`);
dialog.visible = false; dialog.visible = false;
getList(); getList();
// 提交成功后,刷新选项,以防有新的类别/类型被创建
getOptionsList(); getOptionsList();
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.msg || '保存失败'); ElMessage.error(error.msg || '保存失败');
@ -667,6 +854,10 @@ const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm)); form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = []; fileListImage.value = [];
fileListManual.value = []; fileListManual.value = [];
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
imageExternalUrl.value = ''; imageExternalUrl.value = '';
manualExternalUrl.value = ''; manualExternalUrl.value = '';
if (formRef.value) formRef.value.resetFields(); if (formRef.value) formRef.value.resetFields();
@ -692,7 +883,6 @@ const handleDelete = (row: MaterialBaseVO) => {
ElMessage.success("删除成功"); ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--; if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
getList(); getList();
// 删除后也刷新选项
getOptionsList(); getOptionsList();
}); });
}).catch(() => {}); }).catch(() => {});
@ -762,7 +952,6 @@ const triggerCamera = (field: 'generalImage' | 'generalManual') => {
} }
const handleCameraConfirm = async (file: File) => { const handleCameraConfirm = async (file: File) => {
console.log('✅ 父组件收到照片:', file.name)
if (!beforeAvatarUpload(file)) { if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false; cameraDialogVisible.value = false;
return; return;
@ -770,7 +959,6 @@ const handleCameraConfirm = async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// 修复点:使用 ElLoading
const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' }); const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
try { try {
@ -801,7 +989,7 @@ const handleCameraConfirm = async (file: File) => {
onMounted(() => { onMounted(() => {
getList(); getList();
getOptionsList(); // 初始化下拉选项 getOptionsList();
}); });
</script> </script>
@ -831,7 +1019,12 @@ onMounted(() => {
flex-direction: column; flex-direction: column;
} }
/* 上传相关样式 */ .pagination-container {
margin-top: 15px;
display: flex;
justify-content: flex-start;
}
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; } .upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; } :deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; } :deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
@ -840,7 +1033,14 @@ onMounted(() => {
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
/* 表格缩略图样式 */
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; } .file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); } .more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
</style>
<style>
/* 增加下拉框的最大高度,使其能容纳更多选项而不必频繁滚动 */
.long-dropdown .el-select-dropdown__wrap {
max-height: 600px !important; /* 可以根据屏幕大小适当调整 */
}
</style> </style>

View File

@ -2,14 +2,30 @@
<div class="buy-module"> <div class="buy-module">
<div class="header-container"> <div class="header-container">
<div class="search-form-area"> <div class="search-form-area">
<el-select
v-model="queryParams.company"
placeholder="所属公司"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="请输入名称或规格" placeholder="请输入名称或规格"
class="filter-item-input" class="filter-item-input"
clearable clearable
@clear="fetchData"
@keyup.enter="fetchData" @keyup.enter="fetchData"
style="width: 240px;" style="width: 240px;"
/> >
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select <el-select
v-model="queryParams.category" v-model="queryParams.category"
@ -114,6 +130,10 @@
<span class="avail-num">{{ scope.row.qty_available }}</span> <span class="avail-num">{{ scope.row.qty_available }}</span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'tax_rate'">
<span style="color: #909399;">{{ scope.row.tax_rate }}%</span>
</template>
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)"> <template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;"> <div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image <el-image
@ -188,20 +208,23 @@
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"> <div class="card-title">
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Box/></el-icon> <el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span> <span>1. 基础信息</span>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span> <span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 15px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
placeholder="输入名称或规格..." clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced" :remote-method="handleSearchMaterialDebounced"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
@ -209,8 +232,11 @@
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
v-loadmore="loadMoreMaterials" v-loadmore="loadMoreMaterials"
:teleported="false" popper-class="long-dropdown"
> >
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
:key="item.id" :key="item.id"
@ -218,32 +244,40 @@
:value="item.id" :value="item.id"
> >
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <div class="opt-main">
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> </div>
<div class="opt-meta">
<span class="opt-spec" :title="item.spec">{{ item.spec || '-' }}</span>
</div>
<div class="opt-tags">
<el-tag size="small" type="info" effect="light" class="company-tag">{{ item.company_name }}</el-tag>
<el-tag v-if="item.isHistory" size="small" type="warning" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div>
</el-option> </el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 5px; background: #fff;"> <div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中... <el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div> </div>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="14" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
<span class="search-tip"> <span class="search-tip">
<el-icon><InfoFilled/></el-icon> 支持名称/规格型号模糊搜索;滚动可加载更多。 <el-icon><InfoFilled/></el-icon> 支持名称规格型号、公司名称模糊搜索
</span> </span>
</el-col> </el-col>
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label=""><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label=""><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -263,7 +297,7 @@
<el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item> <el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码"/></el-form-item> <el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="库位" prop="warehouse_location"> <el-form-item label="库位" prop="warehouse_location">
@ -315,6 +349,13 @@
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/> <el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6" v-if="dialogStatus === 'create'">
<el-form-item label="打印份数" prop="print_copies">
<el-input-number v-model="form.print_copies" :min="1" :max="999" controls-position="right" style="width:100%"/>
</el-form-item>
</el-col>
<template v-if="dialogStatus === 'update'"> <template v-if="dialogStatus === 'update'">
<el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
@ -391,7 +432,19 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width:100%">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="13%" :value="13" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="不含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
@ -495,7 +548,16 @@
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div> <div v-else class="empty-preview">正在生成预览...</div>
</div> </div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"><p>打印机 IP: 192.168.9.205</p><p>尺寸: 40mm x 30mm</p></div> <div style="margin-top: 20px; font-size: 14px; color: #666;">
<p>打印机 IP: 192.168.9.205</p>
<p>尺寸: 40mm x 30mm</p>
<div style="margin-top: 15px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<span style="font-weight: bold; color: #303133;">打印份数:</span>
<el-input-number v-model="printCopies" :min="1" :max="100" size="default" style="width: 120px;" />
</div>
</div>
</div> </div>
<template #footer> <template #footer>
<div class="dialog-footer"><el-button @click="printVisible = false">取消</el-button><el-button type="primary" :loading="printing" @click="confirmPrint"><el-icon><Printer/></el-icon>确认打印</el-button></div> <div class="dialog-footer"><el-button @click="printVisible = false">取消</el-button><el-button type="primary" :loading="printing" @click="confirmPrint"><el-icon><Printer/></el-icon>确认打印</el-button></div>
@ -521,27 +583,30 @@ import {
getUserSuggestions, getUserSuggestions,
getLinkSuggestions, getLinkSuggestions,
getLocationSuggestions, getLocationSuggestions,
getFilterOptions // 新增引入 getFilterOptions
} from '@/api/inbound/buy' } from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
// ------------------------------------ // ------------------------------------
// 自定义指令v-loadmore // 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框)
// ------------------------------------ // ------------------------------------
const vLoadmore = { const vLoadmore = {
mounted(el: any, binding: any) { mounted(el: any, binding: any) {
setTimeout(() => { const checkAndBind = () => {
const SELECT_DOM = el.querySelector('.el-select-dropdown .el-scrollbar__wrap') const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
if (SELECT_DOM) { if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
SELECT_DOM.addEventListener('scroll', function (this: any) { dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1 const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) { if (condition) {
binding.value() binding.value()
} }
}) })
} }
}, 200) }
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
} }
} }
@ -558,9 +623,9 @@ const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
// 存储下拉选项
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([])
const queryParams = reactive({ const queryParams = reactive({
page: 1, page: 1,
@ -568,6 +633,7 @@ const queryParams = reactive({
keyword: '', keyword: '',
category: '', category: '',
material_type: '', material_type: '',
company: '',
statuses: ['在库', '借库'] statuses: ['在库', '借库']
}) })
@ -582,6 +648,8 @@ const printLoading = ref(false)
const printing = ref(false) const printing = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const currentPrintData = ref<any>({}) const currentPrintData = ref<any>({})
const printCopies = ref(1)
const entryMode = ref('batch') const entryMode = ref('batch')
const modeLocked = ref(false) const modeLocked = ref(false)
const dialogImageUrl = ref('') const dialogImageUrl = ref('')
@ -595,6 +663,7 @@ const inspection_report_url = ref('')
// 基础列 // 基础列
const baseColumns = [ const baseColumns = [
{prop: 'company_name', label: '所属公司'},
{prop: 'material_name', label: '名称'}, {prop: 'material_name', label: '名称'},
{prop: 'material_type', label: '类型'}, {prop: 'material_type', label: '类型'},
{prop: 'category', label: '类别'}, {prop: 'category', label: '类别'},
@ -616,7 +685,10 @@ const stockColumns = [
{prop: 'qty_stock', label: '库存数', minWidth: '100'}, {prop: 'qty_stock', label: '库存数', minWidth: '100'},
{prop: 'qty_available', label: '可用数', minWidth: '100'}, {prop: 'qty_available', label: '可用数', minWidth: '100'},
{prop: 'warehouse_loc', label: '库位', minWidth: '120'}, {prop: 'warehouse_loc', label: '库位', minWidth: '120'},
{prop: 'unit_price', label: '单价', minWidth: '120'},
{prop: 'tax_rate', label: '税率', minWidth: '80'},
{prop: 'unit_price', label: '不含税单价', minWidth: '120'},
{prop: 'total_price', label: '总价', minWidth: '120'}, {prop: 'total_price', label: '总价', minWidth: '120'},
{prop: 'currency', label: '币种', minWidth: '80'}, {prop: 'currency', label: '币种', minWidth: '80'},
{prop: 'exchange_rate', label: '汇率', minWidth: '80'}, {prop: 'exchange_rate', label: '汇率', minWidth: '80'},
@ -632,20 +704,27 @@ const stockColumns = [
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
const defaultColumns = [ const defaultColumns = [
'company_name',
'material_name', 'material_type', 'category', 'spec_model', 'unit', 'material_name', 'material_type', 'category', 'spec_model', 'unit',
'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status', 'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status',
'unit_price', 'total_price', 'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report' 'tax_rate', 'unit_price', 'total_price',
'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report'
] ]
const visibleColumnProps = ref(defaultColumns) const visibleColumnProps = ref(defaultColumns)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', id: undefined, base_id: undefined as number | undefined,
company_name: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '',
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, unit_price: 0, total_price: 0,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
arrival_photo: [] as string[], inspection_report: [] as string[] arrival_photo: [] as string[], inspection_report: [] as string[],
print_copies: 1
}) })
@ -755,6 +834,7 @@ const loadMoreMaterials = async () => {
const onMaterialSelected = (val: number) => { const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
form.company_name = item.company_name
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
@ -857,6 +937,7 @@ const fetchOptions = async () => {
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies
} }
} catch (e) { } catch (e) {
console.error("Fetch options failed", e) console.error("Fetch options failed", e)
@ -867,6 +948,7 @@ const resetQuery = () => {
queryParams.keyword = '' queryParams.keyword = ''
queryParams.category = '' queryParams.category = ''
queryParams.material_type = '' queryParams.material_type = ''
queryParams.company = ''
queryParams.page = 1 queryParams.page = 1
fetchData() fetchData()
} }
@ -887,11 +969,15 @@ const handleUpdate = (row: any) => {
resetForm() resetForm()
modeLocked.value = true modeLocked.value = true
Object.assign(form, { Object.assign(form, {
id: row.id, base_id: row.base_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, id: row.id, base_id: row.base_id,
company_name: row.company_name,
material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date, unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status, warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available), in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
unit_price: Number(row.unit_price), total_price: Number(row.total_price), currency: row.currency, exchange_rate: Number(row.exchange_rate), unit_price: Number(row.unit_price), total_price: Number(row.total_price),
tax_rate: Number(row.tax_rate),
currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email, supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link, source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || [] arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || []
@ -904,7 +990,7 @@ const handleUpdate = (row: any) => {
inspection_report_url.value = reportLinks.length > 0 ? reportLinks[0] : '' inspection_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' } else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name }]
visible.value = true visible.value = true
} }
@ -917,6 +1003,7 @@ const submitForm = async () => {
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value) if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item)) const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value) if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) } const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) }
try { try {
if (dialogStatus.value === 'create') { if (dialogStatus.value === 'create') {
@ -924,7 +1011,11 @@ const submitForm = async () => {
ElMessage.success('入库成功') ElMessage.success('入库成功')
if (res.data) { if (res.data) {
ElMessage.info('发送打印指令...') ElMessage.info('发送打印指令...')
try { await executePrint(res.data); ElMessage.success('打印指令已发送') } try {
// [已修改] 传递用户选择的打印份数
await executePrint({ ...res.data, copies: form.print_copies });
ElMessage.success(`打印指令已发送 (x${form.print_copies})`)
}
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) } catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
} }
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
@ -1044,24 +1135,53 @@ const handleCameraConfirm = async (file: File) => {
} }
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } } const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
// ------------------------------------
// 打印逻辑
// ------------------------------------
const handlePrint = async (row: any) => { const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = '' printVisible.value = true;
printLoading.value = true;
previewUrl.value = '';
printCopies.value = 1;
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku } currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data }
catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
} }
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('指令已发送'); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const confirmPrint = async () => {
printing.value = true;
try {
await executePrint({ ...currentPrintData.value, copies: printCopies.value });
ElMessage.success('指令已发送');
printVisible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '打印失败')
} finally {
printing.value = false
}
}
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = '' materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = ''; searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [] }) Object.assign(form, {
id: undefined, base_id: undefined,
company_name: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [],
print_copies: 1
})
} }
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' } const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' }
const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` } const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` }
onMounted(() => { onMounted(() => {
fetchData() fetchData()
fetchOptions() // 加载下拉框数据 fetchOptions()
}) })
</script> </script>
@ -1089,7 +1209,6 @@ onMounted(() => {
/* 输入框样式 */ /* 输入框样式 */
} }
/* 调整下拉框样式以匹配截图 */
.filter-item-select { .filter-item-select {
/* 确保下拉框高度和输入框一致 */ /* 确保下拉框高度和输入框一致 */
} }
@ -1142,16 +1261,33 @@ onMounted(() => {
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; } .avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.sum-tag { margin-left: 4px; transform: scale(0.9); } .sum-tag { margin-left: 4px; transform: scale(0.9); }
:deep(.el-dialog__body) { padding: 0; overflow: hidden; } :deep(.el-dialog__body) { padding: 0; overflow: hidden; }
.dialog-scroll-container { padding: 15px 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 15px; overflow: hidden; } /* [已修改] 增加 min-height 确保弹窗即使内容少时也保持美观高度,防止被下拉框“压垮” */
.dialog-scroll-container { padding: 15px 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; min-height: 450px; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 15px; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px; color: #303133; display: flex; align-items: center; } .card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px; color: #303133; display: flex; align-items: center; }
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; } .card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; } .card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
.card-content { padding: 15px 20px; } .card-content { padding: 15px 20px; }
.basic-card { border-left: 4px solid #409EFF; } .basic-card { border-left: 4px solid #409EFF; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; } .search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; font-size: 13px; } /* 只读输入框样式:纯文本风格 */
.is-text-view :deep(.el-input__wrapper) {
box-shadow: none !important;
background-color: transparent !important;
border-bottom: 1px dashed #dcdfe6;
border-radius: 0;
padding-left: 0;
}
.is-text-view :deep(.el-input__inner) {
color: #303133;
font-weight: 600;
font-size: 14px;
cursor: text;
}
.inbound-card { border-left: 4px solid #67C23A; } .inbound-card { border-left: 4px solid #67C23A; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 12px; margin-bottom: 15px; } .identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 12px; margin-bottom: 15px; }
.custom-radio-group { margin-bottom: 10px; } .custom-radio-group { margin-bottom: 10px; }
@ -1164,9 +1300,53 @@ onMounted(() => {
.divider-text::before { margin-right: 15px; } .divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 15px; } .divider-text::after { margin-left: 15px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 15px 20px; background: #fff; border-top: 1px solid #ebeef5; } .dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 15px 20px; background: #fff; border-top: 1px solid #ebeef5; }
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; } /* [重点优化] 下拉框选项样式 */
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; } .option-item {
display: flex;
align-items: center;
padding: 8px 0;
width: 100%;
}
/* 名称区域:占据剩余空间,但必须有 min-width: 0 以触发 ellipsis */
.opt-main {
flex: 1;
min-width: 0;
margin-right: 10px;
}
.opt-name {
font-weight: 600;
font-size: 14px;
color: #333;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 规格区域:固定较小宽度,靠右对齐 */
.opt-meta {
width: 100px;
text-align: right;
flex-shrink: 0;
margin-right: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.opt-spec {
color: #999;
font-size: 12px;
}
/* 标签区域:不收缩 */
.opt-tags {
display: flex;
gap: 5px;
flex-shrink: 0;
}
.company-tag {
font-weight: bold;
}
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; } .total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; } .preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
.empty-preview { color: #909399; } .empty-preview { color: #909399; }
@ -1181,3 +1361,17 @@ onMounted(() => {
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
</style> </style>
<style>
/* 针对开启 teleport 后,挂载在 body 下的 dropdown */
.long-dropdown {
width: 580px !important; /* 固定宽度,比输入框稍宽以展示更多信息 */
}
.long-dropdown .el-select-dropdown__wrap {
max-height: 320px !important; /* 限制高度,避免遮挡整个弹窗 */
}
/* [新增] 修复清除按钮被内容遮挡的问题 (Element Plus 偶发 Bug) */
.long-dropdown .el-input__suffix {
z-index: 10;
}
</style>

View File

@ -2,16 +2,28 @@
<div class="product-module"> <div class="product-module">
<div class="header-tools"> <div class="header-tools">
<div class="left-tools"> <div class="left-tools">
<el-select
v-model="queryParams.company"
placeholder="所属公司"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / SN / 工单 / 订单号..." placeholder="🔍 搜索物料 / SN / 工单..."
class="search-input" class="filter-item-input"
clearable clearable
@clear="fetchData" @clear="fetchData"
@keyup.enter="fetchData" @keyup.enter="fetchData"
style="width: 300px; margin-right: 10px;" style="width: 260px;"
> >
<template #append><el-button :icon="Search" @click="fetchData" /></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-select
@ -66,6 +78,11 @@
</span> </span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'company_name'">
<el-tag v-if="scope.row.company_name" type="info" effect="plain" size="small" style="font-weight: bold;">{{ scope.row.company_name }}</el-tag>
<span v-else>-</span>
</template>
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)"> <template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
<div v-if="scope.row.serial_number" class="id-cell"> <div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span> <span class="prefix-tag sn">SN</span>
@ -133,21 +150,27 @@
<el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" /> <el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog"> <el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog compact-layout">
<div class="dialog-scroll-container"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"><el-icon class="icon"><Box /></el-icon><span>1. 基础信息</span></div> <div class="card-title">
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Box /></el-icon>
<span>1. 基础信息</span>
</div>
</div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="10">
<el-form-item label="物料搜索" prop="base_id"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
clearable
placeholder="搜名称/规格..." placeholder="搜名称/规格..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
@ -155,15 +178,28 @@
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id"> <el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <div class="opt-main">
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
</div>
<div class="opt-meta">
<span class="opt-spec" :title="item.spec">{{ item.spec || '-' }}</span>
</div>
<div class="opt-tags">
<el-tag size="small" type="info" effect="light" class="company-tag">{{ item.company_name }}</el-tag>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> <el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div>
</el-option> </el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -175,11 +211,12 @@
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label=""><el-input v-model="form.category" disabled class="is-text-view" /></el-form-item></el-col> <el-col :span="8"><el-form-item label=""><el-input v-model="form.material_type" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" readonly class="is-text-view" /></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -190,8 +227,8 @@
<div class="card-content"> <div class="card-content">
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col> <el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" /></el-form-item></el-col> <el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" /></el-form-item></el-col> <el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col> <el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row> </el-row>
@ -389,22 +426,53 @@ import {
updateProductInbound, updateProductInbound,
deleteProductInbound, deleteProductInbound,
searchMaterialBase, searchMaterialBase,
searchBom // [新增] searchBom,
getFilterOptions // [新增]
} from '@/api/inbound/product' } from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { getLabelPreview, executePrint } from '@/api/common/print' import { getLabelPreview, executePrint } from '@/api/common/print'
// ------------------------------------
// v-loadmore
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) {
binding.value()
}
})
}
}
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
}
}
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false) const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'] }) const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'], company: '' })
const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增]
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关 // BOM 搜索相关
const bomSearchLoading = ref(false) const bomSearchLoading = ref(false)
@ -432,6 +500,7 @@ const inspection_url = ref('')
// [核心优化] 所有列定义 // [核心优化] 所有列定义
const allColumns = [ const allColumns = [
{ prop: 'company_name', label: '所属公司', minWidth: '100' }, // [新增]
{ prop: 'material_name', label: '名称', minWidth: '140' }, { prop: 'material_name', label: '名称', minWidth: '140' },
{ prop: 'sku', label: 'SKU', minWidth: '110' }, { prop: 'sku', label: 'SKU', minWidth: '110' },
{ prop: 'serial_number', label: '序列号', minWidth: '130' }, { prop: 'serial_number', label: '序列号', minWidth: '130' },
@ -454,11 +523,13 @@ const allColumns = [
{ prop: 'detail_link', label: '详情', minWidth: '100' } { prop: 'detail_link', label: '详情', minWidth: '100' }
] ]
const defaultVisibleCols = ['material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id'] const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref(defaultVisibleCols) const visibleColumnProps = ref(defaultVisibleCols)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', id: undefined, base_id: undefined,
company_name: '', // [新增]
material_name: '', spec_model: '', material_type: '', category: '', unit: '',
sku: '', barcode: '', serial_number: '', in_date: '', sku: '', barcode: '', serial_number: '', in_date: '',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, in_quantity: 1, stock_quantity: 1, available_quantity: 1,
warehouse_location: '', status: '在库', quality_status: '合格', warehouse_location: '', status: '在库', quality_status: '合格',
@ -513,18 +584,53 @@ const rules = {
// ------------------------------------ // ------------------------------------
// Material Search & Population Logic // Material Search & Population Logic
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') } const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query) const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false })) const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults materialOptions.value = apiResults
hasNextPage.value = res.has_next
} finally { searchLoading.value = false } } finally { searchLoading.value = false }
} }
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
} else {
hasNextPage.value = false
}
} catch (e) {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
}
const onMaterialSelected = (val: number) => { const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
form.company_name = item.company_name // [新增]
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.material_type = item.type form.material_type = item.type
@ -552,6 +658,28 @@ const fetchData = async () => {
} finally { loading.value = false } } finally { loading.value = false }
} }
const fetchOptions = async () => {
try {
const res: any = await getFilterOptions()
if (res.code === 200) {
categoryOptions.value = res.data.categories
typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
}
} catch (e) {
console.error("Fetch options failed", e)
}
}
const resetQuery = () => {
queryParams.keyword = ''
queryParams.category = ''
queryParams.material_type = ''
queryParams.company = ''
queryParams.page = 1
fetchData()
}
const handleCreate = () => { const handleCreate = () => {
dialogStatus.value = 'create' dialogStatus.value = 'create'
resetForm() resetForm()
@ -582,7 +710,7 @@ const handleUpdate = (row: any) => {
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })) inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r)) const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : '' inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
// 回显BOM // 回显BOM
if (form.bom_code) { if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }] bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -705,7 +833,10 @@ const resetForm = () => {
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning') const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info') const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}` const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => fetchData()) onMounted(() => {
fetchData()
fetchOptions()
})
</script> </script>
<style scoped> <style scoped>
@ -747,4 +878,31 @@ onMounted(() => fetchData())
.camera-card:hover { border-color: #409EFF; color: #409EFF; } .camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
/* [重点] 下拉框 Flex 布局 */
.option-item { display: flex; align-items: center; padding: 8px 0; width: 100%; }
.opt-main { flex: 1; min-width: 0; margin-right: 10px; }
.opt-name { font-weight: 600; font-size: 14px; color: #333; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-meta { width: 100px; text-align: right; flex-shrink: 0; margin-right: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-spec { color: #999; font-size: 12px; }
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
.company-tag { font-weight: bold; }
/* [新增] 修复 filter-item-select/input 样式 */
.filter-item-select { /* 宽度已在行内样式控制 */ }
.filter-item-input { /* 宽度已在行内样式控制 */ }
.action-btn { font-weight: 500; }
/* [新增] 修复弹窗最小高度 */
.dialog-scroll-container { min-height: 450px; }
/* [新增] 纯文本样式 */
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: transparent !important; border-bottom: 1px dashed #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #303133; font-weight: 600; font-size: 14px; cursor: text; }
</style>
<style>
.long-dropdown { width: 580px !important; }
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.long-dropdown .el-input__suffix { z-index: 10; }
</style> </style>

View File

@ -1,17 +1,36 @@
<template> <template>
<div class="semi-module"> <div class="semi-module">
<div class="header-tools"> <div class="header-tools">
<div class="left-tools" style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;"> <div class="left-tools">
<el-select
v-model="queryParams.company"
placeholder="所属公司"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="请输入名称或规格" placeholder="请输入名称或规格"
class="filter-item-input"
clearable clearable
@clear="fetchData"
@keyup.enter="fetchData" @keyup.enter="fetchData"
style="width: 240px;" style="width: 240px;"
/> >
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select <el-select
v-model="queryParams.category" v-model="queryParams.category"
placeholder="类别" placeholder="类别"
class="filter-item-select"
clearable clearable
filterable filterable
@change="fetchData" @change="fetchData"
@ -19,9 +38,11 @@
> >
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
placeholder="类型" placeholder="类型"
class="filter-item-select"
clearable clearable
filterable filterable
@change="fetchData" @change="fetchData"
@ -29,14 +50,16 @@
> >
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" /> <el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-button type="primary" plain @click="fetchData">搜索</el-button>
<el-button @click="resetQuery">重置</el-button> <el-button type="primary" plain class="search-btn" @click="fetchData">搜索</el-button>
<el-button class="reset-btn" @click="resetQuery">重置</el-button>
<el-select <el-select
v-model="queryParams.statuses" v-model="queryParams.statuses"
multiple multiple
collapse-tags collapse-tags
placeholder="状态筛选" placeholder="状态筛选"
style="width: 220px;" style="width: 200px; margin-left: 10px;"
@change="fetchData" @change="fetchData"
> >
<el-option label="在库" value="在库" /> <el-option label="在库" value="在库" />
@ -46,12 +69,12 @@
</div> </div>
<div class="right-tools"> <div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">半成品入库登记</el-button> <el-button type="primary" :icon="Plus" @click="handleCreate" class="add-btn">半成品入库</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button> <el-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click"> <el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference> <template #reference>
<el-button :icon="Setting" class="action-btn">表头</el-button> <el-button :icon="Setting" circle class="circle-btn" />
</template> </template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div> <div class="col-group-title">基础信息</div>
@ -95,6 +118,11 @@
</span> </span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'company_name'">
<el-tag v-if="scope.row.company_name" type="info" effect="plain" size="small" style="font-weight: bold;">{{ scope.row.company_name }}</el-tag>
<span v-else>-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'sn_bn'"> <template #default="scope" v-else-if="col.prop === 'sn_bn'">
<div v-if="scope.row.serial_number" class="id-cell"> <div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span> <span class="prefix-tag sn">SN</span>
@ -195,35 +223,41 @@
top="5vh" top="5vh"
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
class="stylish-dialog" class="stylish-dialog compact-layout"
> >
<div class="dialog-scroll-container"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"> <div class="card-title">
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Box/></el-icon> <el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span> <span>1. 基础信息</span>
</div>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span> <span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
</div> </div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
placeholder="输入名称或规格..." clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template>
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
:key="item.id" :key="item.id"
@ -231,29 +265,40 @@
:value="item.id" :value="item.id"
> >
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <div class="opt-main">
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
</div>
<div class="opt-meta">
<span class="opt-spec" :title="item.spec">{{ item.spec || '-' }}</span>
</div>
<div class="opt-tags">
<el-tag size="small" type="info" effect="light" class="company-tag">{{ item.company_name }}</el-tag>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> <el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div>
</el-option> </el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="14" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
<span class="search-tip"> <span class="search-tip">
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索 <el-icon><InfoFilled/></el-icon> 支持名称、规格型号、公司名称模糊搜索
</span> </span>
</el-col> </el-col>
</el-row> </el-row>
<div class="read-only-grid"> <div class="read-only-grid">
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="所属公司"><el-input v-model="form.company_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label=""><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col> <el-col :span="8"><el-form-item label=""><el-input v-model="form.category" readonly class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" readonly class="is-text-view"/></el-form-item></el-col>
</el-row> </el-row>
</div> </div>
</div> </div>
@ -269,8 +314,8 @@
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col> <el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col> <el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01"/></el-form-item></el-col> <el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" clearable/></el-form-item></el-col>
</el-row> </el-row>
<div class="identity-panel"> <div class="identity-panel">
@ -366,13 +411,15 @@
<div class="form-card production-card"> <div class="form-card production-card">
<div class="card-title"> <div class="card-title">
<div style="display: flex; align-items: center;">
<el-icon class="icon"><Setting/></el-icon> <el-icon class="icon"><Setting/></el-icon>
<span>3. 生产与成本信息</span> <span>3. 生产与成本信息</span>
</div> </div>
</div>
<div class="card-content"> <div class="card-content">
<div class="divider-text">生产任务信息</div> <div class="divider-text">生产任务信息</div>
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" placeholder="WO-xxx"/></el-form-item></el-col> <el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" placeholder="WO-xxx" clearable/></el-form-item></el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="BOM编号"> <el-form-item label="BOM编号">
@ -440,7 +487,7 @@
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button> <el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn"> <el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }} {{ dialogStatus === 'create' ? '提交并打印' : '保存修改' }}
</el-button> </el-button>
</div> </div>
</template> </template>
@ -480,13 +527,35 @@ import {
updateSemiInbound, updateSemiInbound,
deleteSemiInbound, deleteSemiInbound,
searchMaterialBase, searchMaterialBase,
searchBom, // [新增] searchBom,
getFilterOptions getFilterOptions
} from '@/api/inbound/semi' } from '@/api/inbound/semi'
import { uploadFile, deleteFile } from '@/api/inbound/buy' import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue' import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
// ------------------------------------
// 自定义指令v-loadmore (适配 Teleport 到 Body 的下拉框)
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
const dropDownWrap = document.querySelector('.long-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) {
binding.value()
}
})
}
}
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
}
}
// ------------------------------------ // ------------------------------------
// 状态与变量 // 状态与变量
// ------------------------------------ // ------------------------------------
@ -494,14 +563,20 @@ const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false) const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'] }) const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '' })
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增]
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关 // BOM 搜索相关
const bomSearchLoading = ref(false) const bomSearchLoading = ref(false)
@ -529,6 +604,7 @@ const modeLocked = ref(false)
// 列定义 // 列定义
const baseColumns = [ const baseColumns = [
{prop: 'company_name', label: '所属公司'}, // [新增]
{prop: 'material_name', label: '名称'}, {prop: 'material_name', label: '名称'},
{prop: 'category', label: '类别'}, {prop: 'category', label: '类别'},
{prop: 'material_type', label: '类型'}, {prop: 'material_type', label: '类型'},
@ -564,15 +640,13 @@ const stockColumns = [
] ]
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
const defaultColumns = ['material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link'] const defaultColumns = ['company_name', 'material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const visibleColumnProps = ref(defaultColumns) const visibleColumnProps = ref(defaultColumns)
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', id: undefined, base_id: undefined as number | undefined,
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', company_name: '', // [新增]
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0,
production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
}) })
// ------------------------------------ // ------------------------------------
@ -609,18 +683,53 @@ const handleManagerSelect = (item: any) => {
// ------------------------------------ // ------------------------------------
// Material Search (Matches Buy.vue) // Material Search (Matches Buy.vue)
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') } const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query) const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false})) const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults materialOptions.value = apiResults
hasNextPage.value = res.has_next
} finally { searchLoading.value = false } } finally { searchLoading.value = false }
} }
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
} else {
hasNextPage.value = false
}
} catch (e) {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
}
const onMaterialSelected = (val: number) => { const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
form.company_name = item.company_name // [新增]
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
@ -699,6 +808,7 @@ const fetchOptions = async () => {
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
} }
} catch (e) { } catch (e) {
console.error('Fetch options failed', e) console.error('Fetch options failed', e)
@ -709,6 +819,7 @@ const resetQuery = () => {
queryParams.keyword = '' queryParams.keyword = ''
queryParams.category = '' queryParams.category = ''
queryParams.material_type = '' queryParams.material_type = ''
queryParams.company = ''
queryParams.page = 1 queryParams.page = 1
fetchData() fetchData()
} }
@ -729,7 +840,9 @@ const handleUpdate = (row: any) => {
resetForm() resetForm()
modeLocked.value = true modeLocked.value = true
Object.assign(form, { Object.assign(form, {
id: row.id, base_id: row.base_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, id: row.id, base_id: row.base_id,
company_name: row.company_name, // [新增]
material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date, unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status, warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available), in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
@ -748,7 +861,7 @@ const handleUpdate = (row: any) => {
quality_report_url.value = reportLinks.length > 0 ? reportLinks[0] : '' quality_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' } else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
// 回显BOM如果存在 // 回显BOM如果存在
if (form.bom_code) { if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }] bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -862,7 +975,10 @@ const handlePrint = async (row: any) => {
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('指令已发送'); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } } const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('指令已发送'); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = '' materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' }) Object.assign(form, {
id: undefined, base_id: undefined,
company_name: '', // [新增]
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' })
} }
const getStatusType = (status: string) => { const map: any = { '在库': 'success', '出库': 'info', '借库': 'warning', '损耗': 'danger' }; return map[status] || 'warning' } const getStatusType = (status: string) => { const map: any = { '在库': 'success', '出库': 'info', '借库': 'warning', '损耗': 'danger' }; return map[status] || 'warning' }
const getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' } const getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' }
@ -889,7 +1005,10 @@ onMounted(() => {
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; } .avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.id-cell { display: flex; align-items: center; } .id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; } .id-text { font-family: monospace; color: #606266; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 20px; overflow: hidden; } /* [修改] 增加 min-height */
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; min-height: 450px; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 20px; }
.card-title { background: #fcfcfc; padding: 12px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 15px; color: #303133; display: flex; align-items: center; } .card-title { background: #fcfcfc; padding: 12px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 15px; color: #303133; display: flex; align-items: center; }
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; } .card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; } .card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
@ -908,13 +1027,19 @@ onMounted(() => {
.divider-text::before { margin-right: 15px; } .divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 15px; } .divider-text::after { margin-left: 15px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding: 20px; border-top: 1px solid #ebeef5; } .dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding: 20px; border-top: 1px solid #ebeef5; }
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; } .filter-item-select { /* 宽度已在行内样式控制 */ }
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; } .filter-item-input { /* 宽度已在行内样式控制 */ }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; } .search-btn { background-color: #E6F1FC; border-color: #A3D0FD; color: #409EFF; }
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; } .search-btn:hover { background-color: #409EFF; border-color: #409EFF; color: #fff; }
.reset-btn { background-color: #fff; border: 1px solid #dcdfe6; }
.reset-btn:hover { border-color: #c0c4cc; color: #606266; }
/* [优化] 纯文本样式 */
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: transparent !important; border-bottom: 1px dashed #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #303133; font-weight: 600; font-size: 14px; cursor: text; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; } .search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; } .upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; } :deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; } :deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
@ -922,4 +1047,19 @@ onMounted(() => {
.camera-card:hover { border-color: #409EFF; color: #409EFF; } .camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; } .camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; } .camera-card .el-icon { font-size: 24px; }
/* [重点] 下拉框 Flex 布局 */
.option-item { display: flex; align-items: center; padding: 8px 0; width: 100%; }
.opt-main { flex: 1; min-width: 0; margin-right: 10px; }
.opt-name { font-weight: 600; font-size: 14px; color: #333; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-meta { width: 100px; text-align: right; flex-shrink: 0; margin-right: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-spec { color: #999; font-size: 12px; }
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
.company-tag { font-weight: bold; }
</style>
<style>
.long-dropdown { width: 580px !important; }
.long-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.long-dropdown .el-input__suffix { z-index: 10; }
</style> </style>

View File

@ -16,7 +16,7 @@
border border
style="width: 100%" style="width: 100%"
> >
<el-table-column prop="username" label="用户" width="150" /> <el-table-column prop="username" label="用户标识" min-width="180" />
<el-table-column prop="department" label="所属部门" width="150"> <el-table-column prop="department" label="所属部门" width="150">
<template #default="scope"> <template #default="scope">
@ -32,27 +32,10 @@
<el-table-column prop="email" label="邮箱" min-width="200" /> <el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="操作" width="180" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button <el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
link <el-popconfirm title="确定要删除该用户吗?" @confirm="handleDelete(scope.row)">
type="primary"
size="small"
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-popconfirm
title="确定要删除该用户吗?此操作无法撤销。"
@confirm="handleDelete(scope.row)"
>
<template #reference> <template #reference>
<el-button link type="danger" size="small">删除</el-button> <el-button link type="danger" size="small">删除</el-button>
</template> </template>
@ -74,18 +57,28 @@
:rules="rules" :rules="rules"
label-width="100px" label-width="100px"
> >
<el-form-item label="用户名" prop="username"> <el-form-item label="真实姓名" prop="cn_name">
<el-input <el-input
v-model="form.username" v-model="form.cn_name"
placeholder="登录账号 (英文)" placeholder="请输入中文姓名 (如: 张三)"
:disabled="isEdit" :disabled="isEdit"
@input="handleNameInput"
/> />
</el-form-item> </el-form-item>
<el-form-item <el-form-item label="登录账号" prop="username">
label="密码" <el-input
prop="password" v-model="form.username"
placeholder="自动生成,可修改 (如: zhangsan)"
:disabled="isEdit"
> >
<template #append>
<span v-if="!isEdit" style="font-size: 12px; color: #999;">重复自动+1</span>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
@ -103,24 +96,18 @@
allow-create allow-create
default-first-option default-first-option
> >
<el-option <el-option v-for="item in departmentOptions" :key="item" :label="item" :value="item" />
v-for="item in departmentOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="系统角色" prop="role"> <el-form-item label="系统角色" prop="role">
<el-select v-model="form.role" placeholder="授予权限" style="width: 100%"> <el-select v-model="form.role" placeholder="授予权限" style="width: 100%">
<el-option label="主管" value="supervisor" /> <el-option
<el-option label="财务" value="finance" /> v-for="option in roleOptions"
<el-option label="库管" value="warehouse_manager" /> :key="option.value"
<el-option label="入库员" value="inbound" /> :label="option.label"
<el-option label="出库员" value="outbound" /> :value="option.value"
<el-option label="采购员" value="purchaser" /> />
<el-option label="销售" value="sales" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@ -144,9 +131,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted, computed } from 'vue' import { reactive, ref, onMounted, computed } from 'vue'
import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth' import { createUser, updateUser, getUserList, deleteUser } from '@/api/auth'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { pinyin } from 'pinyin-pro' // ★ 务必安装: npm install pinyin-pro
// --- 状态定义 --- const userStore = useUserStore()
const tableLoading = ref(false) const tableLoading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
@ -154,32 +143,77 @@ const tableData = ref<any[]>([])
const departmentOptions = ref<string[]>([]) const departmentOptions = ref<string[]>([])
const formRef = ref() const formRef = ref()
// [新增] 区分编辑/创建模式
const isEdit = ref(false) const isEdit = ref(false)
const currentId = ref<number | null>(null) const currentId = ref<number | null>(null)
const form = reactive({ const form = reactive({
username: '', cn_name: '', // 真实姓名 (前端输入)
username: '', // 登录账号 (拼音)
password: '', password: '',
department: '', department: '',
role: '', role: '',
email: '' email: ''
}) })
// [关键] 动态规则:编辑模式下密码不是必填项 // ★ 监听中文输入,自动转拼音
const handleNameInput = (val: string) => {
if (isEdit.value) return // 编辑模式下不联动
if (!val) {
form.username = ''
return
}
try {
// toneType: 'none' 去除声调, type: 'array' 转数组后拼接
const pinyinStr = pinyin(val, { toneType: 'none', type: 'array' }).join('')
// 将拼音转小写
form.username = pinyinStr.toLowerCase()
} catch (e) {
// 如果转换失败(比如输入符号),不做处理
}
}
const roleOptions = computed(() => {
const options = [
{ label: '主管', value: 'SUPERVISOR' },
{ label: '财务', value: 'FINANCE' },
{ label: '库管', value: 'WAREHOUSE_MGR' },
{ label: '入库员', value: 'INBOUND' },
{ label: '出库员', value: 'OUTBOUND' },
{ label: '采购员', value: 'PURCHASER' },
{ label: '销售', value: 'SALES' }
]
let role = userStore.user?.role || userStore.role
let username = userStore.user?.username || userStore.name
if (!role) role = localStorage.getItem('role')
if (!username) username = localStorage.getItem('username')
const safeRole = role ? String(role).toUpperCase() : ''
const safeUsername = username ? String(username).toUpperCase() : ''
const isSuperAdmin = (safeRole === 'SUPER_ADMIN') || (safeUsername === 'IRIS')
if (isSuperAdmin) {
options.unshift({ label: '超级管理员', value: 'SUPER_ADMIN' })
}
return options
})
const rules = computed(() => { const rules = computed(() => {
const commonRules = { const commonRules: any = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], cn_name: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
username: [{ required: true, message: '账号不能为空', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }], role: [{ required: true, message: '请选择角色', trigger: 'change' }],
department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }], department: [{ required: true, message: '请输入或选择部门', trigger: ['blur', 'change'] }],
// [新增] 邮箱必填校验规则
email: [ email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' }, { required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] } { type: 'email', message: '请输入正确的邮箱格式', trigger: ['blur', 'change'] }
] ]
} }
// 如果是创建模式,密码必填
if (!isEdit.value) { if (!isEdit.value) {
return { return {
...commonRules, ...commonRules,
@ -189,7 +223,6 @@ const rules = computed(() => {
] ]
} }
} else { } else {
// 如果是编辑模式,密码选填(只有输入了才校验长度)
return { return {
...commonRules, ...commonRules,
password: [ password: [
@ -226,51 +259,65 @@ const extractDepartments = (data: any[]) => {
departmentOptions.value = Array.from(deptSet) departmentOptions.value = Array.from(deptSet)
} }
// 2. 打开新增弹窗
const handleCreate = () => { const handleCreate = () => {
isEdit.value = false isEdit.value = false
currentId.value = null currentId.value = null
resetFormState() // 清空数据 // 重置表单
form.cn_name = ''
form.username = ''
form.password = ''
form.department = ''
form.role = ''
form.email = ''
if (formRef.value) formRef.value.clearValidate()
dialogVisible.value = true dialogVisible.value = true
} }
// [新增] 打开编辑弹窗
const handleEdit = (row: any) => { const handleEdit = (row: any) => {
isEdit.value = true isEdit.value = true
currentId.value = row.id currentId.value = row.id
// 回数据 // 回数据
form.username = row.username // 注意:后端返回的 row.username 已经是 "张三(zhangsan01)" 格式
const displayStr = row.username || ''
if (displayStr.includes('(') && displayStr.includes(')')) {
const parts = displayStr.split('(')
form.cn_name = parts[0]
form.username = parts[1].replace(')', '')
} else {
// 兼容旧数据
form.cn_name = displayStr
form.username = displayStr
}
form.department = row.department form.department = row.department
form.role = row.role form.role = row.role
form.email = row.email || '' form.email = row.email || ''
form.password = '' // 编辑时不回显密码,留空表示不修改 form.password = ''
if (formRef.value) formRef.value.clearValidate()
dialogVisible.value = true dialogVisible.value = true
} }
// 3. 提交 (兼容创建和修改)
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
if (valid) { if (valid) {
submitLoading.value = true submitLoading.value = true
try { try {
if (isEdit.value && currentId.value) { if (isEdit.value && currentId.value) {
// 编辑逻辑
await updateUser(currentId.value, form) await updateUser(currentId.value, form)
ElMessage.success(`用户 ${form.username} 修改成功!`) ElMessage.success(`用户 ${form.cn_name} 修改成功!`)
} else { } else {
// 创建逻辑 // 创建时,后端会自动处理重复逻辑
await createUser(form) const res: any = await createUser(form)
ElMessage.success(`用户 ${form.username} 创建成功!`) // 这里的 res.data.account_id 会返回最终生成的账号 (如 zhangsan1)
ElMessage.success(`创建成功!登录账号为: ${res.data.account_id} (已自动防重)`)
} }
dialogVisible.value = false dialogVisible.value = false
getList() getList()
} catch (error) { } catch (error) {
// 错误已处理 // request 拦截器会处理错误
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
@ -278,14 +325,10 @@ const onSubmit = async () => {
}) })
} }
// 4. 重置表单
const resetForm = () => { const resetForm = () => {
if (!formRef.value) return if (!formRef.value) return
formRef.value.resetFields() formRef.value.resetFields()
resetFormState() form.cn_name = ''
}
const resetFormState = () => {
form.username = '' form.username = ''
form.password = '' form.password = ''
form.department = '' form.department = ''
@ -293,39 +336,30 @@ const resetFormState = () => {
form.email = '' form.email = ''
} }
// 5. 删除用户
const handleDelete = async (row: any) => { const handleDelete = async (row: any) => {
try { try {
await deleteUser(row.id) await deleteUser(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
getList() getList()
} catch (error) { } catch (error) {
// 错误处理
} }
} }
// --- 辅助显示方法 ---
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
const formatRole = (val: string) => { const formatRole = (val: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
'supervisor': '主管', 'SUPER_ADMIN': '超级管理员',
'finance': '财务', 'SUPERVISOR': '主管',
'warehouse_manager': '库管', 'FINANCE': '财务',
'inbound': '入库员', 'WAREHOUSE_MGR': '库管',
'outbound': '库员', 'INBOUND': '库员',
'purchaser': '采购员', 'OUTBOUND': '出库员',
'sales': '销售', 'PURCHASER': '采购员',
'super_admin': '超级管理员' 'SALES': '销售'
} }
return map[val] || val const key = val ? val.toUpperCase() : ''
return map[key] || val
} }
// --- 初始化 ---
onMounted(() => { onMounted(() => {
getList() getList()
}) })