Compare commits
16 Commits
04ee938cd1
...
fdf22b9973
| Author | SHA1 | Date | |
|---|---|---|---|
| fdf22b9973 | |||
| bdee5fb27a | |||
| 70f75cc72b | |||
| 94d3149bd9 | |||
| 6131b474a1 | |||
| 164988ab62 | |||
| 8138e8cf5f | |||
| b57e9f5bba | |||
| c06b96f149 | |||
| 03aea51e9a | |||
| 592830c213 | |||
| 89a29f0b65 | |||
| 49453d47f6 | |||
| 1b88171985 | |||
| f234ca6793 | |||
| 3f398b74e5 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,3 +15,6 @@ inventory-web/*.local
|
|||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
pgdata_docker/
|
||||||
|
inventory-backend/uploads/
|
||||||
|
.aider*
|
||||||
|
|||||||
@ -1,20 +1,25 @@
|
|||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
|
# 首先创建主蓝图,避免子模块导入时出现循环依赖
|
||||||
|
inbound_bp = Blueprint('inbound', __name__)
|
||||||
|
|
||||||
|
# 导入各子模块的蓝图(此时 inbound_bp 已定义,子模块可以安全导入它)
|
||||||
from .buy import inbound_buy_bp
|
from .buy import inbound_buy_bp
|
||||||
from .semi import inbound_semi_bp
|
from .semi import inbound_semi_bp
|
||||||
from .base import inbound_base_bp
|
from .base import inbound_base_bp
|
||||||
from .product import inbound_product_bp
|
from .product import inbound_product_bp
|
||||||
from .inbound_summary import bp as inbound_summary_bp
|
from .inbound_summary import bp as inbound_summary_bp
|
||||||
# ★ [新增] 导入 stock 模块
|
from .stock import bp as inbound_stock_bp
|
||||||
from .stock import bp as stock_bp
|
|
||||||
|
|
||||||
inbound_bp = Blueprint('inbound', __name__)
|
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
|
||||||
|
from . import service
|
||||||
|
|
||||||
|
# 注册子蓝图
|
||||||
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
|
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
|
||||||
inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi')
|
inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi')
|
||||||
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
|
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
|
||||||
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
|
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
|
||||||
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
|
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
|
||||||
|
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
|
||||||
|
|
||||||
# ★ [新增] 挂载 stock 模块,路径前缀为 /stock
|
# service 模块的路由已经直接附加到 inbound_bp,无需再注册子蓝图
|
||||||
# 最终访问路径例:/api/v1/inbound/stock/all
|
|
||||||
inbound_bp.register_blueprint(stock_bp, url_prefix='/stock')
|
|
||||||
|
|||||||
140
inventory-backend/app/api/v1/inbound/service.py
Normal file
140
inventory-backend/app/api/v1/inbound/service.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
from flask import request, jsonify, current_app
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
from . import inbound_bp
|
||||||
|
from app.schemas.stock_schema import stock_service_schema
|
||||||
|
from app.services.inbound.service_service import ServiceService
|
||||||
|
from app.utils.decorators import role_required
|
||||||
|
|
||||||
|
|
||||||
|
@inbound_bp.route('/service/search-base', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def search_base():
|
||||||
|
"""搜索基础物料"""
|
||||||
|
keyword = request.args.get('keyword', '')
|
||||||
|
try:
|
||||||
|
data = ServiceService.search_base_material(keyword)
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': 'success',
|
||||||
|
'data': data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'搜索基础物料失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
@inbound_bp.route('/service', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_service_list():
|
||||||
|
"""获取服务权益列表"""
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 20, type=int)
|
||||||
|
keyword = request.args.get('keyword', None)
|
||||||
|
start_date = request.args.get('start_date', None)
|
||||||
|
end_date = request.args.get('end_date', None)
|
||||||
|
provider_name = request.args.get('provider_name', None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ServiceService.get_service_list(
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
keyword=keyword,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
provider_name=provider_name
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': 'success',
|
||||||
|
'data': result
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'获取服务列表失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@inbound_bp.route('/service', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@role_required('admin,manager')
|
||||||
|
def create_service():
|
||||||
|
"""创建服务权益"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||||
|
errors = stock_service_schema.validate(data)
|
||||||
|
if errors:
|
||||||
|
return jsonify({'code': 400, 'msg': '数据校验失败', 'errors': errors}), 400
|
||||||
|
try:
|
||||||
|
service = ServiceService.create_service(data)
|
||||||
|
return jsonify({
|
||||||
|
'code': 201,
|
||||||
|
'msg': '创建成功',
|
||||||
|
'data': stock_service_schema.dump(service)
|
||||||
|
}), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'创建服务权益失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@inbound_bp.route('/service/<int:service_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_service(service_id):
|
||||||
|
"""获取单个服务权益详情"""
|
||||||
|
try:
|
||||||
|
service = ServiceService.get_service(service_id)
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': 'success',
|
||||||
|
'data': stock_service_schema.dump(service)
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'获取服务权益详情失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
@role_required('admin,manager')
|
||||||
|
def update_service(service_id):
|
||||||
|
"""更新服务权益"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||||
|
# 部分字段不允许更新,可在此过滤
|
||||||
|
allowed_fields = {'sale_price', 'provider_name', 'description'}
|
||||||
|
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||||
|
if not filtered_data:
|
||||||
|
return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400
|
||||||
|
try:
|
||||||
|
service = ServiceService.update_service(service_id, filtered_data)
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': '更新成功',
|
||||||
|
'data': stock_service_schema.dump(service)
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'更新服务权益失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
@role_required('admin,manager')
|
||||||
|
def delete_service(service_id):
|
||||||
|
"""删除服务权益"""
|
||||||
|
try:
|
||||||
|
ServiceService.delete_service(service_id)
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'msg': '删除成功'
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f'删除服务权益失败: {str(e)}')
|
||||||
|
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||||
46
inventory-backend/app/models/inbound/service.py
Normal file
46
inventory-backend/app/models/inbound/service.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
from app import db
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class StockService(db.Model):
|
||||||
|
"""
|
||||||
|
服务权益库存表
|
||||||
|
对应数据库表: stock_service
|
||||||
|
"""
|
||||||
|
__tablename__ = 'stock_service'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
# 关联基础物料信息
|
||||||
|
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||||
|
# 系统生成的SKU,格式 SRV-YYYYMMDD-XXXX
|
||||||
|
sku = db.Column(db.String(64), unique=True, nullable=False)
|
||||||
|
# 售价
|
||||||
|
sale_price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
|
# 服务商名称
|
||||||
|
provider_name = db.Column(db.String(255), nullable=False, default='')
|
||||||
|
# 服务详情/简介
|
||||||
|
description = db.Column(db.Text, default='')
|
||||||
|
# 创建时间与更新时间
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||||
|
# 软删除标志
|
||||||
|
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# 关系(可选)
|
||||||
|
material_base = db.relationship('MaterialBase', backref='service_stocks', lazy='joined')
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""转为字典,用于 API 响应"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'base_id': self.base_id,
|
||||||
|
'sku': self.sku,
|
||||||
|
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
|
||||||
|
'provider_name': self.provider_name,
|
||||||
|
'description': self.description,
|
||||||
|
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
||||||
|
'material_name': self.material_base.name if self.material_base else None,
|
||||||
|
'spec_model': self.material_base.spec_model if self.material_base else None,
|
||||||
|
'unit': self.material_base.unit if self.material_base else None,
|
||||||
|
}
|
||||||
@ -7,27 +7,20 @@ class TransBorrow(db.Model):
|
|||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
borrow_no = db.Column(db.String(100))
|
borrow_no = db.Column(db.String(100))
|
||||||
|
|
||||||
sku = db.Column(db.String(100))
|
sku = db.Column(db.String(100))
|
||||||
source_table = db.Column(db.String(50))
|
source_table = db.Column(db.String(50))
|
||||||
stock_id = db.Column(db.Integer)
|
stock_id = db.Column(db.Integer)
|
||||||
barcode = db.Column(db.String(100))
|
barcode = db.Column(db.String(100))
|
||||||
|
|
||||||
quantity = db.Column(db.Numeric(19, 4))
|
quantity = db.Column(db.Numeric(19, 4))
|
||||||
|
|
||||||
# 借出信息
|
|
||||||
borrower_name = db.Column(db.String(100))
|
borrower_name = db.Column(db.String(100))
|
||||||
borrow_time = db.Column(db.DateTime, default=datetime.now)
|
borrow_time = db.Column(db.DateTime, default=datetime.now)
|
||||||
borrow_signature = db.Column(db.Text) # 借用人签字
|
borrow_signature = db.Column(db.Text)
|
||||||
expected_return_time = db.Column(db.DateTime)
|
expected_return_time = db.Column(db.DateTime)
|
||||||
|
|
||||||
# 归还信息
|
|
||||||
is_returned = db.Column(db.Boolean, default=False)
|
is_returned = db.Column(db.Boolean, default=False)
|
||||||
return_time = db.Column(db.DateTime)
|
return_time = db.Column(db.DateTime)
|
||||||
return_operator = db.Column(db.String(100)) # 库管
|
return_operator = db.Column(db.String(100))
|
||||||
return_signature = db.Column(db.Text) # 库管签字
|
return_signature = db.Column(db.Text)
|
||||||
return_location = db.Column(db.String(100)) # 归还库位
|
return_location = db.Column(db.String(100))
|
||||||
|
|
||||||
status = db.Column(db.String(20), default='borrowed')
|
status = db.Column(db.String(20), default='borrowed')
|
||||||
remark = db.Column(db.Text)
|
remark = db.Column(db.Text)
|
||||||
|
|
||||||
@ -36,18 +29,91 @@ class TransBorrow(db.Model):
|
|||||||
'id': self.id,
|
'id': self.id,
|
||||||
'borrow_no': self.borrow_no,
|
'borrow_no': self.borrow_no,
|
||||||
'sku': self.sku,
|
'sku': self.sku,
|
||||||
|
'source_table': self.source_table,
|
||||||
|
'stock_id': self.stock_id,
|
||||||
'barcode': self.barcode,
|
'barcode': self.barcode,
|
||||||
'quantity': float(self.quantity) if self.quantity else 0,
|
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||||
'borrower_name': self.borrower_name,
|
'borrower_name': self.borrower_name,
|
||||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else '',
|
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
|
||||||
'borrow_signature': self.borrow_signature,
|
'borrow_signature': self.borrow_signature,
|
||||||
'expected_return_time': self.expected_return_time.strftime(
|
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
|
||||||
'%Y-%m-%d %H:%M') if self.expected_return_time else '',
|
|
||||||
'is_returned': self.is_returned,
|
'is_returned': self.is_returned,
|
||||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else '',
|
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None,
|
||||||
'return_operator': self.return_operator,
|
'return_operator': self.return_operator,
|
||||||
'return_signature': self.return_signature,
|
'return_signature': self.return_signature,
|
||||||
'return_location': self.return_location,
|
'return_location': self.return_location,
|
||||||
'status': self.status,
|
'status': self.status,
|
||||||
'remark': self.remark
|
'remark': self.remark,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TransRepair(db.Model):
|
||||||
|
__tablename__ = 'trans_repair'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
sku = db.Column(db.String(100))
|
||||||
|
source_table = db.Column(db.String(50))
|
||||||
|
stock_id = db.Column(db.Integer)
|
||||||
|
arrival_date = db.Column(db.Date)
|
||||||
|
expected_repair_time = db.Column(db.String(100))
|
||||||
|
shipping_date = db.Column(db.Date)
|
||||||
|
is_self_made = db.Column(db.Boolean, default=False)
|
||||||
|
related_product_id = db.Column(db.Integer)
|
||||||
|
related_contract_id = db.Column(db.String(100))
|
||||||
|
repair_manager = db.Column(db.String(100))
|
||||||
|
fault_description = db.Column(db.Text)
|
||||||
|
repair_result = db.Column(db.Text)
|
||||||
|
cost_price = db.Column(db.Numeric(19, 4))
|
||||||
|
sale_price = db.Column(db.Numeric(19, 4))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'sku': self.sku,
|
||||||
|
'source_table': self.source_table,
|
||||||
|
'stock_id': self.stock_id,
|
||||||
|
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
|
||||||
|
'expected_repair_time': self.expected_repair_time,
|
||||||
|
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
|
||||||
|
'is_self_made': self.is_self_made,
|
||||||
|
'related_product_id': self.related_product_id,
|
||||||
|
'related_contract_id': self.related_contract_id,
|
||||||
|
'repair_manager': self.repair_manager,
|
||||||
|
'fault_description': self.fault_description,
|
||||||
|
'repair_result': self.repair_result,
|
||||||
|
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
|
||||||
|
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TransScrap(db.Model):
|
||||||
|
__tablename__ = 'trans_scrap'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
sku = db.Column(db.String(100))
|
||||||
|
source_table = db.Column(db.String(50))
|
||||||
|
stock_id = db.Column(db.Integer)
|
||||||
|
quantity = db.Column(db.Numeric(19, 4))
|
||||||
|
reason = db.Column(db.Text)
|
||||||
|
operator_name = db.Column(db.String(100))
|
||||||
|
operation_time = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
approver_name = db.Column(db.String(100))
|
||||||
|
approval_status = db.Column(db.String(20), default='pending')
|
||||||
|
cost_at_scrap = db.Column(db.Numeric(19, 4))
|
||||||
|
total_loss = db.Column(db.Numeric(19, 4))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'sku': self.sku,
|
||||||
|
'source_table': self.source_table,
|
||||||
|
'stock_id': self.stock_id,
|
||||||
|
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||||
|
'reason': self.reason,
|
||||||
|
'operator_name': self.operator_name,
|
||||||
|
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
|
||||||
|
'approver_name': self.approver_name,
|
||||||
|
'approval_status': self.approval_status,
|
||||||
|
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
|
||||||
|
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
|
||||||
}
|
}
|
||||||
@ -38,5 +38,28 @@ class StockBuySchema(Schema):
|
|||||||
# 这里暂时不强制抛出错误,交给 Service 层处理 "SKU不存在且无名字" 的情况
|
# 这里暂时不强制抛出错误,交给 Service 层处理 "SKU不存在且无名字" 的情况
|
||||||
|
|
||||||
|
|
||||||
|
class StockServiceSchema(Schema):
|
||||||
|
# 只用于输出的字段
|
||||||
|
id = fields.Int(dump_only=True)
|
||||||
|
sku = fields.Str(dump_only=True)
|
||||||
|
created_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
|
||||||
|
updated_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
|
||||||
|
material_name = fields.Str(dump_only=True)
|
||||||
|
spec_model = fields.Str(dump_only=True)
|
||||||
|
unit = fields.Str(dump_only=True)
|
||||||
|
|
||||||
|
# 输入字段
|
||||||
|
base_id = fields.Int(required=True, error_messages={"required": "必须选择基础物料"})
|
||||||
|
sale_price = fields.Float(required=True, validate=validate.Range(min=0, error="售价不能为负数"))
|
||||||
|
provider_name = fields.Str(required=True, error_messages={"required": "服务商名称不能为空"})
|
||||||
|
description = fields.Str(missing='')
|
||||||
|
|
||||||
|
@validates_schema
|
||||||
|
def validate_base_id(self, data, **kwargs):
|
||||||
|
# 可以在这里添加对 base_id 是否存在的检查,但更建议在 Service 层进行
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 实例化 Schema
|
# 实例化 Schema
|
||||||
stock_buy_schema = StockBuySchema()
|
stock_buy_schema = StockBuySchema()
|
||||||
|
stock_service_schema = StockServiceSchema()
|
||||||
|
|||||||
155
inventory-backend/app/services/inbound/service_service.py
Normal file
155
inventory-backend/app/services/inbound/service_service.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from app import db
|
||||||
|
from app.models.inbound.service import StockService
|
||||||
|
from app.models.base import MaterialBase
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceService:
|
||||||
|
"""服务权益库存业务逻辑"""
|
||||||
|
|
||||||
|
SKU_PREFIX = 'SRV'
|
||||||
|
SKU_DATE_FORMAT = '%Y%m%d'
|
||||||
|
SKU_SUFFIX_LEN = 4
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _generate_sku(cls):
|
||||||
|
"""生成唯一SKU,格式 SRV-YYYYMMDD-XXXX"""
|
||||||
|
today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT)
|
||||||
|
prefix = f'{cls.SKU_PREFIX}-{today_str}-'
|
||||||
|
# 查找今天已有的最大后缀
|
||||||
|
max_sku = db.session.query(db.func.max(StockService.sku)).filter(
|
||||||
|
StockService.sku.like(f'{prefix}%')
|
||||||
|
).scalar()
|
||||||
|
if not max_sku:
|
||||||
|
suffix_num = 1
|
||||||
|
else:
|
||||||
|
# 提取后缀数字
|
||||||
|
suffix_part = max_sku.replace(prefix, '')
|
||||||
|
match = re.match(r'^(\d+)', suffix_part)
|
||||||
|
suffix_num = int(match.group(1)) if match else 0
|
||||||
|
suffix_num += 1
|
||||||
|
# 格式化为4位数字,左侧补零
|
||||||
|
suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN)
|
||||||
|
return f'{prefix}{suffix}'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search_base_material(cls, keyword):
|
||||||
|
"""搜索基础物料,供前端远程选择"""
|
||||||
|
try:
|
||||||
|
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||||
|
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||||
|
results = []
|
||||||
|
for item in query.all():
|
||||||
|
results.append({
|
||||||
|
'id': item.id,
|
||||||
|
'name': item.name,
|
||||||
|
'spec': item.spec_model,
|
||||||
|
'category': item.category,
|
||||||
|
'unit': item.unit,
|
||||||
|
'type': item.material_type,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_service(cls, data):
|
||||||
|
"""创建服务权益记录"""
|
||||||
|
# 检查基础物料是否存在
|
||||||
|
base = MaterialBase.query.get(data.get('base_id'))
|
||||||
|
if not base:
|
||||||
|
raise ValueError('基础物料不存在')
|
||||||
|
# 生成SKU
|
||||||
|
sku = cls._generate_sku()
|
||||||
|
service = StockService(
|
||||||
|
base_id=data['base_id'],
|
||||||
|
sku=sku,
|
||||||
|
sale_price=data['sale_price'],
|
||||||
|
provider_name=data['provider_name'],
|
||||||
|
description=data.get('description', '')
|
||||||
|
)
|
||||||
|
db.session.add(service)
|
||||||
|
db.session.commit()
|
||||||
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_service(cls, service_id):
|
||||||
|
"""获取单个服务权益"""
|
||||||
|
service = StockService.query.filter_by(id=service_id, is_deleted=False).first()
|
||||||
|
if not service:
|
||||||
|
raise ValueError('服务权益记录不存在')
|
||||||
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_service(cls, service_id, data):
|
||||||
|
"""更新服务权益记录"""
|
||||||
|
service = cls.get_service(service_id)
|
||||||
|
# 不允许修改 base_id 和 sku(业务上不允许变更基础物料)
|
||||||
|
if 'sale_price' in data:
|
||||||
|
service.sale_price = data['sale_price']
|
||||||
|
if 'provider_name' in data:
|
||||||
|
service.provider_name = data['provider_name']
|
||||||
|
if 'description' in data:
|
||||||
|
service.description = data.get('description', '')
|
||||||
|
service.updated_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_service(cls, service_id):
|
||||||
|
"""软删除服务权益"""
|
||||||
|
service = cls.get_service(service_id)
|
||||||
|
service.is_deleted = True
|
||||||
|
service.updated_at = datetime.now()
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_service_list(cls, page=1, per_page=20, keyword=None,
|
||||||
|
start_date=None, end_date=None, provider_name=None):
|
||||||
|
"""分页查询服务权益列表"""
|
||||||
|
query = StockService.query.filter_by(is_deleted=False)
|
||||||
|
# 关键词搜索:可搜索 SKU 或 关联物料名称
|
||||||
|
if keyword:
|
||||||
|
# 子查询查找物料名称匹配的 base_id
|
||||||
|
subquery = MaterialBase.query.filter(
|
||||||
|
MaterialBase.name.ilike(f'%{keyword}%')
|
||||||
|
).subquery()
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
StockService.sku.ilike(f'%{keyword}%'),
|
||||||
|
StockService.base_id.in_([row.id for row in db.session.query(subquery.c.id)])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if start_date:
|
||||||
|
start = datetime.strptime(start_date, '%Y-%m-%d')
|
||||||
|
query = query.filter(StockService.created_at >= start)
|
||||||
|
if end_date:
|
||||||
|
end = datetime.strptime(end_date, '%Y-%m-%d')
|
||||||
|
# 包含当天
|
||||||
|
end = end + timedelta(days=1) - timedelta(seconds=1)
|
||||||
|
query = query.filter(StockService.created_at <= end)
|
||||||
|
if provider_name:
|
||||||
|
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
|
||||||
|
# 总数
|
||||||
|
total = query.count()
|
||||||
|
# 分页
|
||||||
|
items = query.order_by(StockService.created_at.desc())\
|
||||||
|
.offset((page - 1) * per_page)\
|
||||||
|
.limit(per_page).all()
|
||||||
|
return {
|
||||||
|
'items': [item.to_dict() for item in items],
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page
|
||||||
|
}
|
||||||
@ -4,12 +4,11 @@ import os
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
# 引入条形码生成库
|
# 引入二维码生成库
|
||||||
try:
|
try:
|
||||||
import barcode
|
import qrcode
|
||||||
from barcode.writer import ImageWriter
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("❌ 警告: 未安装 python-barcode 库,无法生成真实条形码。请执行: pip install python-barcode")
|
print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]")
|
||||||
|
|
||||||
|
|
||||||
class LabelPrintService:
|
class LabelPrintService:
|
||||||
@ -25,53 +24,65 @@ class LabelPrintService:
|
|||||||
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
|
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
|
||||||
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
|
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
|
||||||
|
|
||||||
# 顶部留白
|
# ================= 2. 布局配置 =================
|
||||||
TOP_MARGIN_MM = 1.5
|
MARGIN_LEFT = int(2 * DOTS_PER_MM) # 左边距 2mm
|
||||||
TOP_MARGIN_PX = int(TOP_MARGIN_MM * DOTS_PER_MM)
|
MARGIN_RIGHT = int(1 * DOTS_PER_MM) # 右边距 1mm
|
||||||
|
TOP_MARGIN = int(5 * DOTS_PER_MM) # 顶部边距 2mm
|
||||||
|
|
||||||
# 定义左边距 (3mm) - 用于正文左对齐
|
# 二维码尺寸 15mm * 15mm
|
||||||
MARGIN_LEFT = int(3 * DOTS_PER_MM)
|
QR_SIZE_MM = 15
|
||||||
# 定义右边距 (防止文字贴边,留 2mm)
|
QR_SIZE_PX = int(QR_SIZE_MM * DOTS_PER_MM) # 180px
|
||||||
MARGIN_RIGHT = int(2 * DOTS_PER_MM)
|
|
||||||
|
|
||||||
# 计算文字允许的最大像素宽度
|
# 左右分栏的间距
|
||||||
MAX_TEXT_WIDTH = LABEL_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
|
GAP_COLUMNS = int(2 * DOTS_PER_MM) # 2mm 间距
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_font(size):
|
def _get_font(size):
|
||||||
"""获取字体"""
|
"""获取字体 (优先使用黑体/微软雅黑)"""
|
||||||
# 尝试加载中文字体,否则乱码
|
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf", "NotoSansCJK-Regular.ttc"]
|
||||||
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf"]
|
base_dirs = [os.getcwd(), os.path.dirname(__file__), "/usr/share/fonts", "C:\\Windows\\Fonts"]
|
||||||
base_dirs = [os.getcwd(), os.path.dirname(__file__)]
|
|
||||||
|
|
||||||
for d in base_dirs:
|
for d in base_dirs:
|
||||||
for name in font_names:
|
for name in font_names:
|
||||||
path = os.path.join(d, name)
|
path = os.path.join(d, name)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
return ImageFont.truetype(path, size)
|
return ImageFont.truetype(path, size)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_barcode_image(content, width_px, height_px):
|
def _generate_qr_image(content, size_px):
|
||||||
"""生成真实的条形码图片"""
|
"""生成指定像素大小的二维码"""
|
||||||
try:
|
try:
|
||||||
if not content: content = "0000000000"
|
if not content: content = "000000"
|
||||||
code128 = barcode.get('code128', content, writer=ImageWriter())
|
|
||||||
buffer = BytesIO()
|
# 创建二维码对象
|
||||||
code128.write(buffer, options={"write_text": False, "module_height": 10.0, "quiet_zone": 1.0})
|
qr = qrcode.QRCode(
|
||||||
buffer.seek(0)
|
version=1,
|
||||||
bc_img = Image.open(buffer)
|
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||||
return bc_img.resize((width_px, height_px), Image.Resampling.LANCZOS)
|
box_size=10,
|
||||||
|
border=0,
|
||||||
|
)
|
||||||
|
qr.add_data(content)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
|
||||||
|
# [重要] 必须转为 RGB 模式
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# 调整为指定像素大小
|
||||||
|
return img.resize((size_px, size_px), Image.Resampling.LANCZOS)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"条形码生成失败: {e}")
|
print(f"二维码生成失败: {e}")
|
||||||
return Image.new('RGB', (width_px, height_px), color='black')
|
return Image.new('RGB', (size_px, size_px), color='gray')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0):
|
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0, stroke_width=1):
|
||||||
"""
|
"""
|
||||||
[核心功能] 自动换行绘制文本
|
[核心功能] 自动换行绘制文本
|
||||||
:param line_spacing: 行与行之间的额外像素距离
|
|
||||||
:return: 绘制结束后的 Y 坐标
|
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return y
|
return y
|
||||||
@ -79,36 +90,36 @@ class LabelPrintService:
|
|||||||
lines = []
|
lines = []
|
||||||
current_line = ""
|
current_line = ""
|
||||||
|
|
||||||
# 1. 计算折行逻辑
|
# 计算折行
|
||||||
for char in text:
|
for char in text:
|
||||||
# 预测加入新字符后的宽度
|
|
||||||
test_line = current_line + char
|
test_line = current_line + char
|
||||||
width = font.getlength(test_line)
|
width = font.getlength(test_line)
|
||||||
|
|
||||||
if width <= max_width:
|
if width <= max_width:
|
||||||
current_line = test_line
|
current_line = test_line
|
||||||
else:
|
else:
|
||||||
# 宽度超出,将当前行存入,新字符作为下一行开头
|
if current_line: lines.append(current_line)
|
||||||
lines.append(current_line)
|
|
||||||
current_line = char
|
current_line = char
|
||||||
|
|
||||||
# 将最后剩余的内容加入
|
|
||||||
if current_line:
|
if current_line:
|
||||||
lines.append(current_line)
|
lines.append(current_line)
|
||||||
|
|
||||||
# 2. 绘制每一行
|
# 绘制
|
||||||
current_y = y
|
current_y = y
|
||||||
font_height = font.size # 获取字号高度
|
font_height = font.size
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
# 边界检查:如果超出图片高度,停止绘制
|
|
||||||
if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
|
if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
|
||||||
break
|
break
|
||||||
|
|
||||||
# 绘制文字 (stroke_width=1 加粗)
|
draw.text(
|
||||||
draw.text((x, current_y), line, font=font, fill='black', stroke_width=1, stroke_fill='black')
|
(x, current_y),
|
||||||
|
line,
|
||||||
# 更新 Y 坐标 (字高 + 行间距)
|
font=font,
|
||||||
|
fill='black',
|
||||||
|
stroke_width=stroke_width, # 支持动态调整粗细
|
||||||
|
stroke_fill='black'
|
||||||
|
)
|
||||||
current_y += font_height + line_spacing
|
current_y += font_height + line_spacing
|
||||||
|
|
||||||
return current_y
|
return current_y
|
||||||
@ -117,121 +128,144 @@ class LabelPrintService:
|
|||||||
def _create_image_object(data):
|
def _create_image_object(data):
|
||||||
"""
|
"""
|
||||||
[绘图层] 生成标签图片
|
[绘图层] 生成标签图片
|
||||||
|
新布局逻辑:
|
||||||
|
---------------------------------------
|
||||||
|
| [QR Code] (15mm) | 名: XXXXXX |
|
||||||
|
| | 规: XXXXXX |
|
||||||
|
| SKU: XXXXX(大/粗)| 属: XXXXXX |
|
||||||
|
| 库: XXXXX (中/粗)| SN: XXXXXX |
|
||||||
|
---------------------------------------
|
||||||
"""
|
"""
|
||||||
# 1. 创建画布
|
# 1. 创建画布
|
||||||
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
|
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
|
||||||
d = ImageDraw.Draw(img)
|
d = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# 2. 字体配置
|
# 2. 字体配置 (字号再次加大)
|
||||||
# [保持] 正文内容维持 24号,以节省空间
|
# [修改] 通用字体加大到 28
|
||||||
font_body = LabelPrintService._get_font(24)
|
font_text = LabelPrintService._get_font(28)
|
||||||
# [修改] 编码(SKU)字体设置为 30号
|
# [修改] SKU字体加大到 34 (特大)
|
||||||
font_code = LabelPrintService._get_font(30)
|
font_sku = LabelPrintService._get_font(34)
|
||||||
|
|
||||||
# 3. 数据准备
|
# 3. 数据准备
|
||||||
sku_code = data.get('sku')
|
sku_code = str(data.get('sku') or data.get('serial_number') or '000000')
|
||||||
if not sku_code:
|
|
||||||
sku_code = data.get('serial_number') or str(data.get('global_print_id', '0000000000')).zfill(10)
|
|
||||||
|
|
||||||
# ==================== 绘制布局 ====================
|
|
||||||
|
|
||||||
GLOBAL_OFFSET_X = LabelPrintService.MARGIN_LEFT
|
|
||||||
CURRENT_Y = LabelPrintService.TOP_MARGIN_PX
|
|
||||||
|
|
||||||
# --- A. 绘制条形码 (居中) ---
|
|
||||||
bc_w = int(37 * LabelPrintService.DOTS_PER_MM)
|
|
||||||
bc_h = int(8 * LabelPrintService.DOTS_PER_MM) # 高度
|
|
||||||
|
|
||||||
bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h)
|
|
||||||
|
|
||||||
# [修改核心] 计算条形码的居中 X 坐标
|
|
||||||
# 公式:(标签总宽 - 条码宽) / 2
|
|
||||||
bc_x_centered = (LabelPrintService.LABEL_WIDTH - bc_w) // 2
|
|
||||||
|
|
||||||
img.paste(bc_img, (bc_x_centered, CURRENT_Y))
|
|
||||||
|
|
||||||
# --- B. 绘制条形码下方数字 (居中 + 30号字) ---
|
|
||||||
text_y_pos = CURRENT_Y + bc_h + 2
|
|
||||||
|
|
||||||
# [修改核心] 计算文字宽度 并 居中
|
|
||||||
text_width = font_code.getlength(sku_code)
|
|
||||||
text_x_centered = (LabelPrintService.LABEL_WIDTH - text_width) // 2
|
|
||||||
|
|
||||||
d.text(
|
|
||||||
(text_x_centered, text_y_pos),
|
|
||||||
sku_code,
|
|
||||||
font=font_code, # 使用30号字体
|
|
||||||
fill='black',
|
|
||||||
stroke_width=1,
|
|
||||||
stroke_fill='black'
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新 Y 坐标,准备开始绘制正文
|
|
||||||
# 30(字高) + 4(间距)
|
|
||||||
CURRENT_Y = text_y_pos + 30 + 4
|
|
||||||
|
|
||||||
# --- C. 绘制其余信息 (保持左对齐 + 24号字 + 自动换行) ---
|
|
||||||
|
|
||||||
# 1. 准备完整文本
|
|
||||||
name = str(data.get('material_name', '') or '-')
|
name = str(data.get('material_name', '') or '-')
|
||||||
spec = str(data.get('spec_model', '') or '-')
|
spec = str(data.get('spec_model', '') or '-')
|
||||||
loc = str(data.get('warehouse_loc', '') or '-')
|
loc = str(data.get('warehouse_loc', '') or '-')
|
||||||
|
|
||||||
cat = str(data.get('category', '') or '')
|
cat = str(data.get('category', '') or '')
|
||||||
typ = str(data.get('material_type', '') or '')
|
typ = str(data.get('material_type', '') or '')
|
||||||
attr = f"{cat}/{typ}"
|
attr = f"{cat}/{typ}" if (cat or typ) else "-"
|
||||||
|
|
||||||
# 底部文字
|
# 底部编号逻辑
|
||||||
bottom_text = ""
|
bottom_val = ""
|
||||||
|
bottom_label = "NO"
|
||||||
if data.get('print_no'):
|
if data.get('print_no'):
|
||||||
val = str(data.get('print_no'))
|
bottom_val = str(data.get('print_no'))
|
||||||
label_type = data.get('print_label', '')
|
l_type = data.get('print_label', '')
|
||||||
bottom_text = f"{'SN' if label_type == '序' else 'BN' if label_type == '批' else 'NO'}: {val}"
|
bottom_label = 'SN' if l_type == '序' else 'BN' if l_type == '批' else 'NO'
|
||||||
elif data.get('serial_number'):
|
elif data.get('serial_number'):
|
||||||
bottom_text = f"SN: {data.get('serial_number')}"
|
bottom_label = "SN"
|
||||||
|
bottom_val = str(data.get('serial_number'))
|
||||||
elif data.get('batch_number'):
|
elif data.get('batch_number'):
|
||||||
bottom_text = f"BN: {data.get('batch_number')}"
|
bottom_label = "BN"
|
||||||
|
bottom_val = str(data.get('batch_number'))
|
||||||
else:
|
else:
|
||||||
bottom_text = f"NO: {sku_code}"
|
bottom_val = sku_code
|
||||||
|
|
||||||
# 2. 依次调用自动换行绘制函数 (使用正文字体 font_body,且坐标使用 GLOBAL_OFFSET_X)
|
bottom_text_full = f"{bottom_label}:{bottom_val}"
|
||||||
|
|
||||||
# 绘制名称
|
# ==================== 绘制区域划分 ====================
|
||||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
|
||||||
d, f"名: {name}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
# --- A. 左侧区域 (二维码 + SKU + 库位) ---
|
||||||
|
qr_x = LabelPrintService.MARGIN_LEFT
|
||||||
|
qr_y = LabelPrintService.TOP_MARGIN
|
||||||
|
|
||||||
|
# 1. 绘制二维码
|
||||||
|
qr_img = LabelPrintService._generate_qr_image(sku_code, LabelPrintService.QR_SIZE_PX)
|
||||||
|
img.paste(qr_img, (qr_x, qr_y))
|
||||||
|
|
||||||
|
# 计算中心点,用于 SKU 和 库位 居中
|
||||||
|
qr_center_x = qr_x + (LabelPrintService.QR_SIZE_PX // 2)
|
||||||
|
|
||||||
|
# 2. 绘制 SKU (特大 + 特粗)
|
||||||
|
# 位于二维码下方,留 6px 间距
|
||||||
|
current_left_y = qr_y + LabelPrintService.QR_SIZE_PX + 6
|
||||||
|
|
||||||
|
sku_w = font_sku.getlength(sku_code)
|
||||||
|
sku_x = int(qr_center_x - (sku_w // 2))
|
||||||
|
if sku_x < 2: sku_x = 2 # 边界保护
|
||||||
|
|
||||||
|
d.text(
|
||||||
|
(sku_x, current_left_y),
|
||||||
|
sku_code,
|
||||||
|
font=font_sku,
|
||||||
|
fill='black',
|
||||||
|
stroke_width=2, # [修改] SKU 增加到 2px 描边,更粗
|
||||||
|
stroke_fill='black'
|
||||||
)
|
)
|
||||||
CURRENT_Y += 2
|
|
||||||
|
|
||||||
# 绘制规格
|
# 3. 绘制 库位 (放在 SKU 下方)
|
||||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
# 位于 SKU 下方,留 6px 间距
|
||||||
d, f"规: {spec}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
current_left_y += 34 + 6 # 34是字号大致高度
|
||||||
|
|
||||||
|
loc_text = f"库:{loc}"
|
||||||
|
loc_w = font_text.getlength(loc_text)
|
||||||
|
loc_x = int(qr_center_x - (loc_w // 2))
|
||||||
|
if loc_x < 2: loc_x = 2
|
||||||
|
|
||||||
|
d.text(
|
||||||
|
(loc_x, current_left_y),
|
||||||
|
loc_text,
|
||||||
|
font=font_text,
|
||||||
|
fill='black',
|
||||||
|
stroke_width=1, # 普通加粗
|
||||||
|
stroke_fill='black'
|
||||||
)
|
)
|
||||||
CURRENT_Y += 2
|
|
||||||
|
|
||||||
# 绘制库位
|
# --- B. 右侧区域 (名称、规格、属性、编号) ---
|
||||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
|
||||||
d, f"库: {loc}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
# 右侧起始 X
|
||||||
|
right_start_x = LabelPrintService.MARGIN_LEFT + LabelPrintService.QR_SIZE_PX + LabelPrintService.GAP_COLUMNS
|
||||||
|
# 右侧最大宽度
|
||||||
|
right_max_width = LabelPrintService.LABEL_WIDTH - right_start_x - LabelPrintService.MARGIN_RIGHT
|
||||||
|
|
||||||
|
current_right_y = LabelPrintService.TOP_MARGIN
|
||||||
|
|
||||||
|
# [修改] 增大行间距 line_spacing=8
|
||||||
|
LINE_SPACING = 8
|
||||||
|
|
||||||
|
# 1. 名称
|
||||||
|
current_right_y = LabelPrintService.draw_text_wrap(
|
||||||
|
d, f"名:{name}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||||
)
|
)
|
||||||
CURRENT_Y += 2
|
current_right_y += LINE_SPACING
|
||||||
|
|
||||||
# 绘制属性
|
# 2. 规格
|
||||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
current_right_y = LabelPrintService.draw_text_wrap(
|
||||||
d, f"属: {attr}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
d, f"规:{spec}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||||
)
|
)
|
||||||
CURRENT_Y += 2
|
current_right_y += LINE_SPACING
|
||||||
|
|
||||||
# 绘制底部编号
|
# 3. 属性
|
||||||
CURRENT_Y = LabelPrintService.draw_text_wrap(
|
current_right_y = LabelPrintService.draw_text_wrap(
|
||||||
d, bottom_text, GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
|
d, f"属:{attr}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||||
|
)
|
||||||
|
current_right_y += LINE_SPACING
|
||||||
|
|
||||||
|
# 4. 序列号/批号
|
||||||
|
LabelPrintService.draw_text_wrap(
|
||||||
|
d, bottom_text_full, right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||||
)
|
)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_preview_image(data):
|
def generate_preview_image(data):
|
||||||
|
"""生成 Base64 预览图"""
|
||||||
img = LabelPrintService._create_image_object(data)
|
img = LabelPrintService._create_image_object(data)
|
||||||
output_buffer = BytesIO()
|
output_buffer = BytesIO()
|
||||||
img.save(output_buffer, format='JPEG')
|
img.save(output_buffer, format='JPEG', quality=95)
|
||||||
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
|
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
|
||||||
return f"data:image/jpeg;base64,{base64_str}"
|
return f"data:image/jpeg;base64,{base64_str}"
|
||||||
|
|
||||||
@ -241,12 +275,21 @@ class LabelPrintService:
|
|||||||
port = LabelPrintService.PRINTER_PORT
|
port = LabelPrintService.PRINTER_PORT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 1. 获取 RGB 图像
|
||||||
img_rgb = LabelPrintService._create_image_object(data)
|
img_rgb = LabelPrintService._create_image_object(data)
|
||||||
|
|
||||||
|
# 2. 转换为灰度
|
||||||
img_gray = img_rgb.convert('L')
|
img_gray = img_rgb.convert('L')
|
||||||
img_bw = img_gray.convert('1', dither=Image.Dither.NONE)
|
|
||||||
|
# 3. 二值化处理
|
||||||
|
img_bw = img_gray.point(lambda x: 0 if x < 128 else 255, '1')
|
||||||
|
|
||||||
|
# 4. 生成打印指令
|
||||||
bitmap_data = img_bw.tobytes()
|
bitmap_data = img_bw.tobytes()
|
||||||
width_bytes = (img_bw.width + 7) // 8
|
width_bytes = (img_bw.width + 7) // 8
|
||||||
height_dots = img_bw.height
|
height_dots = img_bw.height
|
||||||
|
|
||||||
|
# TSPL 协议头
|
||||||
header = (
|
header = (
|
||||||
f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n"
|
f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n"
|
||||||
"GAP 2 mm, 0 mm\r\n"
|
"GAP 2 mm, 0 mm\r\n"
|
||||||
@ -254,8 +297,12 @@ class LabelPrintService:
|
|||||||
"DIRECTION 1\r\n"
|
"DIRECTION 1\r\n"
|
||||||
"REFERENCE 0, 0\r\n"
|
"REFERENCE 0, 0\r\n"
|
||||||
).encode('gbk')
|
).encode('gbk')
|
||||||
|
|
||||||
|
# 位图指令
|
||||||
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk')
|
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk')
|
||||||
footer = b"\r\nPRINT 1,1\r\n"
|
footer = b"\r\nPRINT 1,1\r\n"
|
||||||
|
|
||||||
|
# 5. 发送 socket
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s.settimeout(5)
|
s.settimeout(5)
|
||||||
s.connect((ip, port))
|
s.connect((ip, port))
|
||||||
@ -265,3 +312,7 @@ class LabelPrintService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 打印异常: {e}")
|
print(f"❌ 打印异常: {e}")
|
||||||
raise Exception(f"打印机连接失败: {str(e)}")
|
raise Exception(f"打印机连接失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pass
|
||||||
@ -6,7 +6,11 @@ marshmallow-sqlalchemy==1.0.0
|
|||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
flask-cors==4.0.0
|
flask-cors==4.0.0
|
||||||
|
# 图片处理核心库
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
|
||||||
python-barcode>=0.14.0
|
python-barcode>=0.14.0
|
||||||
|
# [新增] 二维码生成库 (标签打印必需,包含PIL支持)
|
||||||
|
qrcode[pil]>=7.4.2
|
||||||
# [新增] 必须添加,用于处理 token 登录
|
# [新增] 必须添加,用于处理 token 登录
|
||||||
Flask-JWT-Extended==4.6.0
|
Flask-JWT-Extended==4.6.0
|
||||||
113
inventory-web/src/api/inbound/service.ts
Normal file
113
inventory-web/src/api/inbound/service.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface ServiceItem {
|
||||||
|
id: number
|
||||||
|
base_id: number
|
||||||
|
sku: string
|
||||||
|
sale_price: number
|
||||||
|
provider_name: string
|
||||||
|
description: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
material_name?: string
|
||||||
|
spec_model?: string
|
||||||
|
unit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceListResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: {
|
||||||
|
items: ServiceItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceQueryParams {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
keyword?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
provider_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceCreateRequest {
|
||||||
|
base_id: number
|
||||||
|
sale_price: number
|
||||||
|
provider_name: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceUpdateRequest {
|
||||||
|
sale_price?: number
|
||||||
|
provider_name?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialBaseItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
spec: string
|
||||||
|
category: string
|
||||||
|
unit: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取服务权益列表
|
||||||
|
export function getServiceList(params: ServiceQueryParams) {
|
||||||
|
return request<ServiceListResponse>({
|
||||||
|
url: '/v1/inbound/service',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建服务权益
|
||||||
|
export function createService(data: ServiceCreateRequest) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/inbound/service',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取服务权益详情
|
||||||
|
export function getServiceDetail(id: number) {
|
||||||
|
return request<ServiceListResponse>({
|
||||||
|
url: `/v1/inbound/service/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新服务权益
|
||||||
|
export function updateService(id: number, data: ServiceUpdateRequest) {
|
||||||
|
return request({
|
||||||
|
url: `/v1/inbound/service/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索基础物料
|
||||||
|
export function searchMaterialBase(keyword: string) {
|
||||||
|
return request<{
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: MaterialBaseItem[]
|
||||||
|
}>({
|
||||||
|
url: '/v1/inbound/service/search-base',
|
||||||
|
method: 'get',
|
||||||
|
params: { keyword }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除服务权益
|
||||||
|
export function deleteService(id: number) {
|
||||||
|
return request({
|
||||||
|
url: `/v1/inbound/service/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div class="focus-tip" v-if="!errorMsg && !isPaused">
|
<div class="focus-tip" v-if="!errorMsg && !isPaused">
|
||||||
<div class="scan-line"></div>
|
<div class="scan-line"></div>
|
||||||
<div class="scan-text">请将条码横向填满红框</div>
|
<div class="scan-text">将条码置于镜头范围内即可</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="focus-tip success" v-if="isPaused">
|
<div class="focus-tip success" v-if="isPaused">
|
||||||
@ -15,25 +15,43 @@
|
|||||||
扫描成功,3秒后继续...
|
扫描成功,3秒后继续...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasZoom" class="zoom-control">
|
||||||
|
<span class="zoom-icon">-</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
:min="zoomMin"
|
||||||
|
:max="zoomMax"
|
||||||
|
step="0.1"
|
||||||
|
v-model="currentZoom"
|
||||||
|
@input="handleZoom"
|
||||||
|
/>
|
||||||
|
<span class="zoom-icon">+</span>
|
||||||
|
<div class="zoom-value">{{ currentZoom }}x</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||||
import { CircleCheckFilled } from '@element-plus/icons-vue' // 引入图标用于成功提示
|
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
// 定义事件
|
|
||||||
const emit = defineEmits(['decode', 'error'])
|
const emit = defineEmits(['decode', 'error'])
|
||||||
|
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const isPaused = ref(false) // ★ 新增:控制暂停状态
|
const isPaused = ref(false)
|
||||||
let html5QrCode: Html5Qrcode | null = null
|
let html5QrCode: Html5Qrcode | null = null
|
||||||
const scannerElementId = "qr-reader"
|
const scannerElementId = "qr-reader"
|
||||||
|
|
||||||
|
// 变焦控制状态
|
||||||
|
const hasZoom = ref(false)
|
||||||
|
const zoomMin = ref(1)
|
||||||
|
const zoomMax = ref(5)
|
||||||
|
const currentZoom = ref(1)
|
||||||
|
|
||||||
const startScanning = async () => {
|
const startScanning = async () => {
|
||||||
try {
|
try {
|
||||||
// 1. 实例化
|
|
||||||
html5QrCode = new Html5Qrcode(scannerElementId, {
|
html5QrCode = new Html5Qrcode(scannerElementId, {
|
||||||
useBarCodeDetectorIfSupported: true,
|
useBarCodeDetectorIfSupported: true,
|
||||||
formatsToSupport: [
|
formatsToSupport: [
|
||||||
@ -43,37 +61,34 @@ const startScanning = async () => {
|
|||||||
verbose: false
|
verbose: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. 启动配置
|
|
||||||
const config = {
|
const config = {
|
||||||
fps: 20,
|
fps: 20,
|
||||||
qrbox: { width: 320, height: 60 },
|
// ★★★ 核心修改点 2:移除了 qrbox 属性 ★★★
|
||||||
|
// 移除后,库默认会对每一帧的“全画面”进行解析,不再局限于中间区域
|
||||||
|
// qrbox: { width: 300, height: 100 },
|
||||||
|
|
||||||
disableFlip: false,
|
disableFlip: false,
|
||||||
videoConstraints: {
|
videoConstraints: {
|
||||||
facingMode: "environment",
|
facingMode: "environment",
|
||||||
width: { min: 1280, ideal: 1920, max: 3840 },
|
// 保持高分辨率以支持微小条码
|
||||||
height: { min: 720, ideal: 1080, max: 2160 },
|
width: { min: 1280, ideal: 3840, max: 3840 },
|
||||||
|
height: { min: 720, ideal: 2160, max: 2160 },
|
||||||
focusMode: "continuous",
|
focusMode: "continuous",
|
||||||
advanced: [{ focusMode: "macro" }]
|
advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 启动
|
|
||||||
await html5QrCode.start(
|
await html5QrCode.start(
|
||||||
{ facingMode: "environment" },
|
{ facingMode: "environment" },
|
||||||
config,
|
config,
|
||||||
(decodedText) => {
|
(decodedText) => {
|
||||||
// ★ 核心修改:如果处于暂停冷却期,直接忽略后续扫描结果
|
|
||||||
if (isPaused.value) return
|
if (isPaused.value) return
|
||||||
|
|
||||||
console.log(`Scan: ${decodedText}`)
|
console.log(`Scan: ${decodedText}`)
|
||||||
|
|
||||||
// 1. 锁定状态
|
|
||||||
isPaused.value = true
|
isPaused.value = true
|
||||||
|
|
||||||
// 2. 发送数据
|
|
||||||
emit('decode', decodedText)
|
emit('decode', decodedText)
|
||||||
|
|
||||||
// 3. 开启 3 秒倒计时解锁
|
if (navigator.vibrate) navigator.vibrate(200);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isPaused.value = false
|
isPaused.value = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
@ -82,20 +97,52 @@ const startScanning = async () => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
checkZoomCapability()
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
let msg = '无法启动摄像头'
|
let msg = '无法启动摄像头'
|
||||||
const errStr = err.toString()
|
|
||||||
if (errStr.includes('Permission')) msg = '请允许摄像头权限'
|
|
||||||
else if (errStr.includes('Secure')) msg = '需要 HTTPS 或 localhost'
|
|
||||||
else if (errStr.includes('NotFound')) msg = '未检测到后置摄像头'
|
|
||||||
else if (errStr.includes('OverconstrainedError')) msg = '摄像头不支持高分辨率'
|
|
||||||
|
|
||||||
console.error("Scanner Error:", err)
|
console.error("Scanner Error:", err)
|
||||||
errorMsg.value = msg
|
errorMsg.value = msg
|
||||||
emit('error', msg)
|
emit('error', msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测硬件变焦能力
|
||||||
|
const checkZoomCapability = () => {
|
||||||
|
if (!html5QrCode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const videoTrack = html5QrCode.getRunningTrackCameraCapabilities() as MediaTrackCapabilities;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (videoTrack && 'zoom' in videoTrack) {
|
||||||
|
hasZoom.value = true
|
||||||
|
// @ts-ignore
|
||||||
|
zoomMin.value = videoTrack.zoom.min || 1
|
||||||
|
// @ts-ignore
|
||||||
|
zoomMax.value = videoTrack.zoom.max || 5
|
||||||
|
// @ts-ignore
|
||||||
|
currentZoom.value = videoTrack.zoom.min || 1
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("无法获取变焦能力", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理滑块拖动
|
||||||
|
const handleZoom = () => {
|
||||||
|
if (!html5QrCode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
html5QrCode.applyVideoConstraints({
|
||||||
|
advanced: [{ zoom: Number(currentZoom.value) }]
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error("变焦失败", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stopScanning = async () => {
|
const stopScanning = async () => {
|
||||||
if (html5QrCode) {
|
if (html5QrCode) {
|
||||||
try {
|
try {
|
||||||
@ -131,7 +178,8 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 12px;
|
/* 如果是全屏模式,这里不需要圆角,或者保持圆角视你的UI设计而定 */
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scanner-box {
|
.scanner-box {
|
||||||
@ -151,7 +199,6 @@ onUnmounted(() => {
|
|||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
object-fit: cover !important;
|
object-fit: cover !important;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
@ -167,62 +214,98 @@ onUnmounted(() => {
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 视觉辅助线 --- */
|
/* --- ★ 修改点 3:视觉层 CSS 更新 --- */
|
||||||
.focus-tip {
|
.focus-tip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
transform: translate(-50%, -50%);
|
width: 100%;
|
||||||
width: 320px;
|
height: 100%;
|
||||||
height: 60px;
|
/* 移除了 border 和 box-shadow,不再显示红框和黑色遮罩 */
|
||||||
border: 2px solid rgba(255, 0, 0, 0.6);
|
|
||||||
border-radius: 6px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.6);
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ★ 扫描成功时的绿色框样式 */
|
|
||||||
.focus-tip.success {
|
.focus-tip.success {
|
||||||
border-color: #67c23a; /* 绿色边框 */
|
background: rgba(103, 194, 58, 0.2); /* 成功时全屏微微泛绿 */
|
||||||
background: rgba(103, 194, 58, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 扫描线改为全屏宽度 */
|
||||||
.scan-line {
|
.scan-line {
|
||||||
width: 95%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: #ff0000;
|
background: rgba(255, 0, 0, 0.5);
|
||||||
box-shadow: 0 0 4px #ff0000;
|
box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
animation: scan-move 1.5s infinite ease-in-out;
|
/* 动画范围从 10% 到 90% */
|
||||||
|
animation: scan-move 2.5s infinite linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-text {
|
.scan-text {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -35px;
|
bottom: 150px; /* 调整文字位置 */
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
white-space: nowrap;
|
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||||
text-shadow: 0 1px 2px #000;
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-text-success {
|
.scan-text-success {
|
||||||
color: #67c23a;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 10px;
|
||||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
||||||
|
background: rgba(103, 194, 58, 0.9);
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan-move {
|
@keyframes scan-move {
|
||||||
0% { top: 10%; opacity: 0.5; }
|
0% { top: 0%; opacity: 0; }
|
||||||
50% { top: 90%; opacity: 1; }
|
10% { opacity: 1; }
|
||||||
100% { top: 10%; opacity: 0.5; }
|
90% { opacity: 1; }
|
||||||
|
100% { top: 100%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 变焦控制器 */
|
||||||
|
.zoom-control {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 80%;
|
||||||
|
max-width: 300px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 30px;
|
||||||
|
z-index: 50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-control input[type=range] {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-value {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 30px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -11,7 +11,6 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
// 2. Actions
|
// 2. Actions
|
||||||
// 登录逻辑
|
// 登录逻辑
|
||||||
const handleLogin = async (loginForm: any) => {
|
const handleLogin = async (loginForm: any) => {
|
||||||
try {
|
|
||||||
const res = await login(loginForm)
|
const res = await login(loginForm)
|
||||||
|
|
||||||
// [调试日志] 查看实际返回的数据结构
|
// [调试日志] 查看实际返回的数据结构
|
||||||
@ -26,7 +25,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
// 安全检查:确保 data 存在且包含 access_token
|
// 安全检查:确保 data 存在且包含 access_token
|
||||||
if (!data || !data.access_token) {
|
if (!data || !data.access_token) {
|
||||||
console.error('Login Error: 响应数据中缺少 access_token', data)
|
console.error('Login Error: 响应数据中缺少 access_token', data)
|
||||||
return false
|
throw new Error('登录失败: 响应数据异常')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Pinia 状态 (内存)
|
// 更新 Pinia 状态 (内存)
|
||||||
@ -46,11 +45,6 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
localStorage.setItem('token', data.access_token)
|
localStorage.setItem('token', data.access_token)
|
||||||
|
|
||||||
return true // 返回 true 表示登录成功
|
return true // 返回 true 表示登录成功
|
||||||
} catch (error) {
|
|
||||||
console.error('Login failed:', error)
|
|
||||||
// 返回 false 表示登录失败,Login 组件会据此停止跳转
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出逻辑
|
// 退出逻辑
|
||||||
|
|||||||
@ -52,15 +52,20 @@ service.interceptors.response.use(
|
|||||||
let message = error.message || '请求失败'
|
let message = error.message || '请求失败'
|
||||||
|
|
||||||
// 处理 HTTP 状态码错误
|
// 处理 HTTP 状态码错误
|
||||||
|
const isLoginEndpoint = error.config && error.config.url.includes('/login')
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const status = error.response.status
|
const status = error.response.status
|
||||||
const data = error.response.data
|
const data = error.response.data
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
|
// 对于登录接口的401错误,不执行登出重定向,仅提示错误
|
||||||
|
if (!isLoginEndpoint) {
|
||||||
message = '登录已过期,请重新登录'
|
message = '登录已过期,请重新登录'
|
||||||
// 这里可以触发登出逻辑
|
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
// 如果是登录接口,message会被后面的data.msg覆盖
|
||||||
} else if (status === 403) {
|
} else if (status === 403) {
|
||||||
message = '权限不足'
|
message = '权限不足'
|
||||||
} else if (status === 404) {
|
} else if (status === 404) {
|
||||||
@ -73,7 +78,10 @@ service.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 登录接口的错误由调用方单独处理,不再显示全局提示
|
||||||
|
if (!isLoginEndpoint) {
|
||||||
ElMessage.error(message)
|
ElMessage.error(message)
|
||||||
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -16,18 +16,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="scan-section">
|
<div class="scan-section">
|
||||||
<div v-if="showCamera" class="camera-wrapper">
|
|
||||||
<QrScanner @decode="onScanSuccess" />
|
|
||||||
<div class="scan-overlay">
|
|
||||||
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
|
||||||
关闭摄像头
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="camera-placeholder" @click="showCamera = true">
|
<div class="camera-placeholder" @click="showCamera = true">
|
||||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||||
<span class="text">点击开启扫码</span>
|
<span class="text">点击开启全屏扫码</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
@ -150,6 +142,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<div v-if="showCamera" class="fullscreen-scanner-overlay">
|
||||||
|
<div class="scanner-header">
|
||||||
|
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
|
||||||
|
<span class="scanner-title">扫码模式</span>
|
||||||
|
<div class="scanner-placeholder"></div> </div>
|
||||||
|
|
||||||
|
<div class="scanner-body">
|
||||||
|
<QrScanner @decode="onScanSuccess" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scanner-footer">
|
||||||
|
<p>请对准条形码,识别成功后自动添加</p>
|
||||||
|
<p v-if="cartItems.length > 0" class="current-count">当前已添加: {{ cartItems.length }} 件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showSignatureDialog"
|
v-model="showSignatureDialog"
|
||||||
fullscreen
|
fullscreen
|
||||||
@ -201,7 +209,7 @@ import { useUserStore } from '@/stores/user'
|
|||||||
const barcodeInput = ref('')
|
const barcodeInput = ref('')
|
||||||
const cartItems = ref<any[]>([])
|
const cartItems = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showCamera = ref(false) // ★ 核心修改:默认改为 false
|
const showCamera = ref(false)
|
||||||
const barcodeRef = ref()
|
const barcodeRef = ref()
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@ -266,26 +274,21 @@ const onScanSuccess = (code: string) => {
|
|||||||
if (!code) return
|
if (!code) return
|
||||||
const trimCode = code.trim()
|
const trimCode = code.trim()
|
||||||
|
|
||||||
// ★★★ 核心修改:防误触校验 ★★★
|
|
||||||
// 1. 正则校验:只允许 数字、字母、横杠、点
|
|
||||||
// 这样可以屏蔽掉条码解析错误产生的 { } $ # 等乱码
|
|
||||||
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
||||||
if (!validPattern.test(trimCode)) {
|
if (!validPattern.test(trimCode)) {
|
||||||
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 长度校验:避免误扫到环境中的短数字
|
|
||||||
if (trimCode.length < 3) {
|
if (trimCode.length < 3) {
|
||||||
ElMessage.warning('扫描结果过短,请对准重试')
|
ElMessage.warning('扫描结果过短,请对准重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防抖:防止同一条码连续触发
|
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
|
|
||||||
barcodeInput.value = trimCode
|
barcodeInput.value = trimCode
|
||||||
handleManualInput() // 复用手动输入逻辑
|
handleManualInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleManualInput = async () => {
|
const handleManualInput = async () => {
|
||||||
@ -343,10 +346,13 @@ const handleManualInput = async () => {
|
|||||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// 聚焦输入框,方便连续扫
|
// 注意:全屏扫码模式下,我们不需要 refocus input,因为用户还在看摄像头
|
||||||
|
// 只有在非全屏模式下才 focus
|
||||||
|
if (!showCamera.value) {
|
||||||
nextTick(() => { barcodeRef.value?.focus() })
|
nextTick(() => { barcodeRef.value?.focus() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const removeFromCart = (index: number) => {
|
const removeFromCart = (index: number) => {
|
||||||
cartItems.value.splice(index, 1)
|
cartItems.value.splice(index, 1)
|
||||||
@ -505,21 +511,73 @@ onUnmounted(() => {
|
|||||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||||
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
||||||
|
|
||||||
/* 扫码区 */
|
/* 扫码区(卡片内触发器) */
|
||||||
.scan-section { margin-bottom: 20px; }
|
.scan-section { margin-bottom: 20px; }
|
||||||
.camera-wrapper {
|
|
||||||
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.scan-overlay {
|
|
||||||
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
|
||||||
}
|
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
color: #909399; margin-bottom: 10px; cursor: pointer;
|
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
.camera-placeholder:active { background: #e6e8eb; }
|
||||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ★ 全屏扫码层样式 */
|
||||||
|
.fullscreen-scanner-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: #000;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-header {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 15px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||||
|
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||||
|
|
||||||
|
.scanner-body {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
/* 强制子组件(QrScanner)填满容器 */
|
||||||
|
:deep(.qr-scanner-container) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||||
|
|
||||||
/* 表单与购物车 */
|
/* 表单与购物车 */
|
||||||
.cart-section { margin-bottom: 20px; }
|
.cart-section { margin-bottom: 20px; }
|
||||||
.form-section { background: #fff; }
|
.form-section { background: #fff; }
|
||||||
|
|||||||
@ -1 +1,493 @@
|
|||||||
<template><div style="padding:20px;"><h2>服务权益管理</h2></div></template>
|
<template>
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h2 style="margin-bottom: 20px;">服务权益管理</h2>
|
||||||
|
|
||||||
|
<div class="header-toolbar">
|
||||||
|
<el-form :inline="true" @submit.prevent>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.keyword"
|
||||||
|
placeholder="SKU/物料名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px;"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务商">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.provider_name"
|
||||||
|
placeholder="服务商名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
style="width: 200px;"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="searchForm.start_date"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束日期">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="searchForm.end_date"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
<el-button type="success" @click="handleAdd">新增服务</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="tableData" border stripe style="width: 100%;" v-loading="loading">
|
||||||
|
<el-table-column prop="sku" label="SKU" width="200" />
|
||||||
|
<el-table-column prop="material_name" label="物料名称" />
|
||||||
|
<el-table-column prop="provider_name" label="服务商" width="150" />
|
||||||
|
<el-table-column prop="sale_price" label="售价" width="120">
|
||||||
|
<template #default="{row}">¥{{ row.sale_price.toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="description" label="简介" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; text-align: center;">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="perPage"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="700px"
|
||||||
|
@close="resetDialog"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<div class="dialog-scroll-container">
|
||||||
|
<div class="form-card basic-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<el-icon class="icon"><Box /></el-icon>
|
||||||
|
<span>1. 基础信息</span>
|
||||||
|
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 15px;">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||||
|
<el-select
|
||||||
|
v-model="form.base_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
placeholder="输入名称或规格..."
|
||||||
|
:remote-method="handleSearchMaterial"
|
||||||
|
@visible-change="handleMaterialDropdownVisible"
|
||||||
|
:loading="searchLoading"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="onMaterialSelected"
|
||||||
|
default-first-option
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in materialOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<div class="option-item">
|
||||||
|
<span class="opt-name">{{ item.name }}</span>
|
||||||
|
<span class="opt-spec">{{ item.spec }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24" style="margin-top: -10px; margin-bottom: 10px;">
|
||||||
|
<span class="search-tip">
|
||||||
|
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
|
||||||
|
</span>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<div class="read-only-grid" v-if="form.base_id">
|
||||||
|
<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.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" 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" disabled 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-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card inbound-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<el-icon class="icon"><House /></el-icon>
|
||||||
|
<span>2. 服务详情</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<el-form-item label="售价" prop="sale_price">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.sale_price"
|
||||||
|
placeholder="请输入售价"
|
||||||
|
:controls="false"
|
||||||
|
:precision="2"
|
||||||
|
:min="0"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务商" prop="provider_name">
|
||||||
|
<el-input
|
||||||
|
v-model="form.provider_name"
|
||||||
|
placeholder="请输入服务商名称"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="简介" prop="description">
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入服务简介"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleDialogConfirm">确认</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { InfoFilled, Box, House } from '@element-plus/icons-vue'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getServiceList,
|
||||||
|
createService,
|
||||||
|
updateService,
|
||||||
|
deleteService,
|
||||||
|
searchMaterialBase,
|
||||||
|
type ServiceItem,
|
||||||
|
type ServiceQueryParams,
|
||||||
|
type ServiceCreateRequest,
|
||||||
|
type MaterialBaseItem
|
||||||
|
} from '@/api/inbound/service'
|
||||||
|
|
||||||
|
// 表格数据
|
||||||
|
const tableData = ref<ServiceItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const materialOptions = ref<any[]>([])
|
||||||
|
const searchLoading = ref(false)
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
keyword: '',
|
||||||
|
provider_name: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载列表
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: ServiceQueryParams = {
|
||||||
|
page: page.value,
|
||||||
|
per_page: perPage.value,
|
||||||
|
keyword: searchForm.keyword || undefined,
|
||||||
|
provider_name: searchForm.provider_name || undefined,
|
||||||
|
start_date: searchForm.start_date || undefined,
|
||||||
|
end_date: searchForm.end_date || undefined
|
||||||
|
}
|
||||||
|
const res = await getServiceList(params)
|
||||||
|
if (res.code === 200) {
|
||||||
|
tableData.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '加载失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('网络错误')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
page.value = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchForm, {
|
||||||
|
keyword: '',
|
||||||
|
provider_name: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
})
|
||||||
|
page.value = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
const handleSizeChange = (val: number) => {
|
||||||
|
perPage.value = val
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
const handlePageChange = (val: number) => {
|
||||||
|
page.value = val
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
|
if (visible && materialOptions.value.length === 0) {
|
||||||
|
handleSearchMaterial('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchMaterial = async (query: string) => {
|
||||||
|
searchLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await searchMaterialBase(query)
|
||||||
|
if (res.code === 200) {
|
||||||
|
materialOptions.value = res.data
|
||||||
|
} else {
|
||||||
|
materialOptions.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
materialOptions.value = []
|
||||||
|
} finally {
|
||||||
|
searchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMaterialSelected = (val: number) => {
|
||||||
|
const item = materialOptions.value.find(i => i.id === val)
|
||||||
|
if (item) {
|
||||||
|
form.material_name = item.name
|
||||||
|
form.spec_model = item.spec
|
||||||
|
form.category = item.category
|
||||||
|
form.unit = item.unit
|
||||||
|
form.material_type = item.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗相关
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogTitle = ref('')
|
||||||
|
const dialogStatus = ref<'create' | 'update'>('create')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const form = reactive({
|
||||||
|
id: 0,
|
||||||
|
base_id: undefined as number | undefined,
|
||||||
|
material_name: '',
|
||||||
|
spec_model: '',
|
||||||
|
category: '',
|
||||||
|
unit: '',
|
||||||
|
material_type: '',
|
||||||
|
sale_price: 0,
|
||||||
|
provider_name: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
const rules = reactive<FormRules>({
|
||||||
|
base_id: [
|
||||||
|
{ required: true, message: '请选择基础物料', trigger: 'change' }
|
||||||
|
],
|
||||||
|
sale_price: [
|
||||||
|
{ required: true, message: '请输入售价', trigger: 'blur' },
|
||||||
|
{ type: 'number', min: 0, message: '售价不能为负数', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
provider_name: [
|
||||||
|
{ required: true, message: '请输入服务商名称', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogTitle.value = '新增服务'
|
||||||
|
dialogStatus.value = 'create'
|
||||||
|
Object.assign(form, {
|
||||||
|
id: 0,
|
||||||
|
base_id: undefined as number | undefined,
|
||||||
|
material_name: '',
|
||||||
|
spec_model: '',
|
||||||
|
category: '',
|
||||||
|
unit: '',
|
||||||
|
material_type: '',
|
||||||
|
sale_price: 0,
|
||||||
|
provider_name: '',
|
||||||
|
description: ''
|
||||||
|
})
|
||||||
|
materialOptions.value = []
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
const handleEdit = (row: ServiceItem) => {
|
||||||
|
dialogTitle.value = '编辑服务'
|
||||||
|
dialogStatus.value = 'update'
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
base_id: row.base_id,
|
||||||
|
material_name: row.material_name || '',
|
||||||
|
spec_model: row.spec_model || '',
|
||||||
|
category: row.category || '',
|
||||||
|
unit: row.unit || '',
|
||||||
|
material_type: row.material_type || '',
|
||||||
|
sale_price: row.sale_price,
|
||||||
|
provider_name: row.provider_name,
|
||||||
|
description: row.description
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDialogConfirm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
try {
|
||||||
|
const reqData: ServiceCreateRequest = {
|
||||||
|
base_id: form.base_id,
|
||||||
|
sale_price: form.sale_price,
|
||||||
|
provider_name: form.provider_name,
|
||||||
|
description: form.description
|
||||||
|
}
|
||||||
|
if (form.id === 0) {
|
||||||
|
await createService(reqData)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
} else {
|
||||||
|
await updateService(form.id, reqData)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || '操作失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const resetDialog = () => {
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (row: ServiceItem) => {
|
||||||
|
ElMessageBox.confirm(`确定删除服务权益 "${row.sku}" 吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteService(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-scroll-container {
|
||||||
|
padding: 15px 20px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.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 .sub-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.card-content {
|
||||||
|
padding: 15px 20px;
|
||||||
|
}
|
||||||
|
.basic-card {
|
||||||
|
border-left: 4px solid #409EFF;
|
||||||
|
}
|
||||||
|
.inbound-card {
|
||||||
|
border-left: 4px solid #67C23A;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.read-only-grid {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.search-tip {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.highlight-label :deep(.el-form-item__label) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409EFF;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user