借库逻辑实现

This commit is contained in:
dxc
2026-02-06 17:11:47 +08:00
parent 387c8973d6
commit 04ee938cd1
15 changed files with 1766 additions and 268 deletions

View File

@ -80,18 +80,21 @@ def create_app():
# ----------------------------------------------------- # -----------------------------------------------------
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废) # 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
# ----------------------------------------------------- # -----------------------------------------------------
try: try:
from app.api.v1.transactions import trans_bp from app.api.v1.transactions import trans_bp
app.register_blueprint(trans_bp, url_prefix='/api/v1/trans') # 标准: /api/v1/transactions/borrow
app.register_blueprint(trans_bp, url_prefix='/api/trans', name='trans_legacy') app.register_blueprint(trans_bp, url_prefix='/api/v1/transactions')
# 兼容: /api/transactions/borrow
app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
print("✅ Transactions 模块注册成功") print("✅ Transactions 模块注册成功")
except ImportError as e: except ImportError as e:
# 允许模块不存在时不崩溃 # 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题
print(f"⚠️ 提示: Transaction 模块尚未创建或导入失败: {e}") print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}")
# ----------------------------------------------------- # -----------------------------------------------------
# 2.5 ★ [新增] 注册出库模块 (Outbound) # 2.5 注册出库模块 (Outbound)
# ----------------------------------------------------- # -----------------------------------------------------
try: try:
from app.api.v1.outbound import outbound_bp from app.api.v1.outbound import outbound_bp
@ -114,11 +117,12 @@ def create_app():
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
# ★ [新增] 出库模型 (确保迁移工具能检测到 trans_outbound 表) # 出库模型
from app.models.outbound import TransOutbound from app.models.outbound import TransOutbound
# 系统与业务模型 # 系统与业务模型
from app.models.system import SysUser, SysLog from app.models.system import SysUser, SysLog
# 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap from app.models.transaction import TransBorrow, TransRepair, TransScrap
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade) # 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)

View File

@ -1,12 +1,58 @@
from flask import Blueprint, jsonify from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.trans_service import TransService
import traceback
# 定义蓝图 trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
# 注意:这个变量名 trans_bp 必须与 app/__init__.py 中注册时引用的名字一致
trans_bp = Blueprint('transactions', __name__)
@trans_bp.route('/test', methods=['GET'])
def test_transaction(): # --- 借库接口 ---
""" @trans_bp.route('/borrow', methods=['POST'])
测试接口:用于验证 Transaction 模块是否加载成功 @jwt_required()
""" def create_borrow():
return jsonify({"message": "Transaction module is working", "status": "success"}) data = request.get_json()
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET'])
@jwt_required()
def scan_borrowed_item():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '无条码'}), 400
res = TransService.scan_for_return(barcode)
if res:
return jsonify({'code': 200, 'data': res})
else:
return jsonify({'code': 404, 'msg': '未找到该物品的未还记录'}), 404
# --- 还库提交 ---
@trans_bp.route('/return', methods=['POST'])
@jwt_required()
def submit_return():
data = request.get_json()
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
def get_records():
status = request.args.get('status', 'all')
page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
return jsonify({'code': 200, 'data': res})

View File

@ -2,95 +2,52 @@ from app.extensions import db
from datetime import datetime from datetime import datetime
# 1. 借用表
class TransBorrow(db.Model): class TransBorrow(db.Model):
__tablename__ = 'trans_borrow' __tablename__ = 'trans_borrow'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
sku = db.Column(db.String(100), index=True) # 加索引优化查询 borrow_no = 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))
quantity = db.Column(db.Numeric(19, 4)) quantity = db.Column(db.Numeric(19, 4))
# 借出信息
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) # 借用人签字
expected_return_time = db.Column(db.DateTime) expected_return_time = db.Column(db.DateTime)
borrower_name = db.Column(db.String(100)) # 归还信息
actual_return_time = db.Column(db.DateTime) is_returned = db.Column(db.Boolean, default=False)
approver_name = db.Column(db.String(100)) return_time = db.Column(db.DateTime)
return_operator = db.Column(db.String(100)) # 库管
return_signature = db.Column(db.Text) # 库管签字
return_location = db.Column(db.String(100)) # 归还库位
# 状态borrowed(借出), returned(已还), overdue(逾期)
status = db.Column(db.String(20), default='borrowed') status = db.Column(db.String(20), default='borrowed')
remark = db.Column(db.Text)
def to_dict(self): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'borrow_no': self.borrow_no,
'sku': self.sku, 'sku': self.sku,
'barcode': self.barcode,
'quantity': float(self.quantity) if self.quantity else 0, 'quantity': float(self.quantity) if self.quantity else 0,
'borrower_name': self.borrower_name, 'borrower_name': self.borrower_name,
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M:%S') if self.borrow_time else None, 'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else '',
'status': self.status 'borrow_signature': self.borrow_signature,
} 'expected_return_time': self.expected_return_time.strftime(
'%Y-%m-%d %H:%M') if self.expected_return_time else '',
'is_returned': self.is_returned,
# 2. 维修表 'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else '',
class TransRepair(db.Model): 'return_operator': self.return_operator,
__tablename__ = 'trans_repair' 'return_signature': self.return_signature,
'return_location': self.return_location,
id = db.Column(db.Integer, primary_key=True) 'status': self.status,
sku = db.Column(db.String(100), index=True) 'remark': self.remark
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,
'status': 'repaired' if self.repair_result else 'pending',
'manager': self.repair_manager
}
# 3. 报废表
class TransScrap(db.Model):
__tablename__ = 'trans_scrap'
id = db.Column(db.Integer, primary_key=True)
sku = db.Column(db.String(100), index=True)
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') # pending, approved, rejected
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,
'quantity': float(self.quantity) if self.quantity else 0,
'total_loss': float(self.total_loss) if self.total_loss else 0,
'reason': self.reason
} }

View File

@ -1,9 +1,8 @@
from app.extensions import db from app.extensions import db
# 引用新的模型类 StockBuy
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
# 尝试导入出库模型,如果不存在则忽略(防止报错影响入库功能) # 尝试导入出库模型,如果不存在则忽略
try: try:
from app.models.outbound import TransOutbound from app.models.outbound import TransOutbound
except ImportError: except ImportError:
@ -17,28 +16,70 @@ import json
class BuyInboundService: class BuyInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (核心修复)
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
"""
校验序列号和批号的唯一性逻辑
:param base_id: 当前物料的基础ID
:param serial_number: 序列号
:param batch_number: 批号
:param exclude_id: 排除的ID (用于编辑模式)
"""
# 1. 序列号 (SN) 全局唯一校验
# 解释: 不同规格的物料通常也不应该有相同的SN防止扫码混淆
if serial_number:
query = StockBuy.query.filter(StockBuy.serial_number == serial_number)
if exclude_id:
query = query.filter(StockBuy.id != exclude_id)
exists = query.first()
if exists:
# 获取占用该SN的物料名称提示更友好
occupied_name = exists.material.name if exists.material else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 同物料唯一校验
# 解释: 不同规格的物料可以有相同的批号(如都有 001 批次),但同一个物料不能重复建单
if batch_number and base_id:
query = StockBuy.query.filter(
StockBuy.base_id == base_id,
StockBuy.batch_number == batch_number
)
if exclude_id:
query = query.filter(StockBuy.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复录入,可直接在该批次下追加库存。")
# ============================================================ # ============================================================
# 1. 基础物料搜索 # 1. 基础物料搜索
# ============================================================ # ============================================================
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword):
"""搜索基础物料"""
try: try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True) query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword: if keyword:
query = query.filter( query = query.filter(
or_( or_(
MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%') MaterialBase.spec_model.ilike(f'%{keyword}%'),
MaterialBase.pinyin.ilike(f'%{keyword}%') # 假设有拼音搜索
) )
) )
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
results = [] results = []
for item in query.all(): for item in query.all():
results.append({ results.append({
'id': item.id, 'name': item.name, 'spec': item.spec_model, 'id': item.id,
'category': item.category, 'unit': item.unit, 'name': item.name,
'type': item.material_type, 'status': '启用' 'spec': item.spec_model, # 确保这里字段对应正确
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'status': '启用'
}) })
return results return results
except Exception as e: except Exception as e:
@ -46,76 +87,76 @@ class BuyInboundService:
return [] return []
# ============================================================ # ============================================================
# 2. 新增入库逻辑 (强制北京时间) # 2. 新增入库逻辑
# ============================================================ # ============================================================
@staticmethod @staticmethod
def handle_inbound(data): def handle_inbound(data):
"""新增入库"""
try: try:
base_id = data.get('base_id') base_id = data.get('base_id')
if not base_id: raise ValueError("必须选择基础物料") if not base_id:
material = MaterialBase.query.get(base_id) raise ValueError("必须选择基础物料")
if not material: raise ValueError("物料不存在")
# [核心修改] 获取当前北京时间 (UTC+8) material = MaterialBase.query.get(base_id)
# 无论服务器在 UTC 还是其他时区,这里强制转换为 UTC+8 并去掉时区信息存入数据库 if not material:
raise ValueError("所选物料不存在")
# --- [修复点] 执行唯一性校验 ---
BuyInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number')
)
# 时间处理 (强制北京时间)
beijing_tz = timezone(timedelta(hours=8)) beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time in_date_val = current_time
if data.get('in_date'): if data.get('in_date'):
try: try:
date_str = str(data['in_date']) date_str = str(data['in_date'])
# 如果前端传了时分秒,尝试直接解析
if len(date_str) > 10: if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else: else:
# 如果只传了日期,使用该日期 + 当前北京时间的时分秒
d_temp = datetime.strptime(date_str, '%Y-%m-%d') d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day, in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second) current_time.hour, current_time.minute, current_time.second)
except: except:
# 解析失败则使用当前北京时间
in_date_val = current_time in_date_val = current_time
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)
# [核心逻辑] 获取全局打印流水号 # 获取全局打印ID
try: try:
seq_sql = text("SELECT nextval('global_print_seq')") seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql) result = db.session.execute(seq_sql)
next_global_id = result.scalar() next_global_id = result.scalar()
except Exception: except:
# 如果序列不存在,回退处理(或在数据库创建序列)
print("Warning: Sequence global_print_seq not found.")
next_global_id = None next_global_id = None
# SKU 生成逻辑:如果没有 ID用临时随机数或空通常应该依赖 next_global_id # SKU 生成
if next_global_id: if next_global_id:
generated_sku = str(next_global_id).zfill(10) generated_sku = str(next_global_id).zfill(10)
else: else:
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S') # 降级方案 generated_sku = datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku final_barcode = data.get('barcode') or generated_sku
arrival_list = data.get('arrival_photo', []) arrival_list = data.get('arrival_photo', [])
report_list = data.get('inspection_report', []) report_list = data.get('inspection_report', [])
if not isinstance(arrival_list, list): arrival_list = []
if not isinstance(report_list, list): report_list = []
new_stock = StockBuy( new_stock = StockBuy(
base_id=material.id, base_id=material.id,
global_print_id=next_global_id, global_print_id=next_global_id,
sku=generated_sku, sku=generated_sku,
barcode=final_barcode, barcode=final_barcode,
in_date=in_date_val, # 存入 DateTime 对象 in_date=in_date_val,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'), batch_number=data.get('batch_number'),
status=data.get('status', '在库'), status=data.get('status', '在库'),
in_quantity=in_qty, in_quantity=in_qty,
stock_quantity=in_qty, stock_quantity=in_qty, # 初始库存等于入库数
available_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'),
@ -143,13 +184,27 @@ class BuyInboundService:
# ============================================================ # ============================================================
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
"""更新入库"""
try: try:
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock:
raise ValueError("记录不存在")
# --- [修复点] 编辑时也要校验唯一性 (排除自身ID) ---
# 如果修改了物料(base_id)或者修改了SN/BN都需要校验
new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number)
BuyInboundService._check_unique(
base_id=new_base_id,
serial_number=new_sn,
batch_number=new_bn,
exclude_id=stock_id
)
# 更新字段
field_mapping = { field_mapping = {
'sku': 'sku', 'barcode': 'barcode', 'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
'warehouse_location': 'warehouse_location', 'warehouse_location': 'warehouse_location',
'serial_number': 'serial_number', 'batch_number': 'batch_number', 'serial_number': 'serial_number', 'batch_number': 'batch_number',
'status': 'status', 'inspection_status': 'inspection_status', 'status': 'status', 'inspection_status': 'inspection_status',
@ -166,9 +221,9 @@ class BuyInboundService:
if 'inspection_report' in data and isinstance(data['inspection_report'], list): if 'inspection_report' in data and isinstance(data['inspection_report'], list):
stock.inspection_report = json.dumps(data['inspection_report']) stock.inspection_report = json.dumps(data['inspection_report'])
# 库存数量变更逻辑
if 'in_quantity' in data: if 'in_quantity' in data:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
# 计算差值,同步更新库存量和可用量
diff = new_qty - float(stock.in_quantity) diff = new_qty - float(stock.in_quantity)
if diff != 0: if diff != 0:
stock.in_quantity = new_qty stock.in_quantity = new_qty
@ -190,7 +245,6 @@ class BuyInboundService:
# ============================================================ # ============================================================
@staticmethod @staticmethod
def delete_inbound(stock_id): def delete_inbound(stock_id):
"""删除入库"""
try: try:
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
@ -202,34 +256,13 @@ class BuyInboundService:
raise e raise e
# ============================================================ # ============================================================
# 5. 获取出库流转历史 # 5. 获取列表
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
"""获取出库历史"""
if not TransOutbound:
return []
try:
records = TransOutbound.query.filter_by(
source_table='stock_buy', stock_id=stock_id
).order_by(TransOutbound.outbound_time.desc()).all()
return [r.to_dict() for r in records]
except:
return []
# ============================================================
# 6. 获取列表 (修改:按时间倒序排序 + 展示只显示日期)
# ============================================================ # ============================================================
@staticmethod @staticmethod
def get_list(page, limit, keyword=None, statuses=None): def get_list(page, limit, keyword=None, statuses=None):
"""
获取列表
:param statuses: 状态列表 (e.g. ['在库', '借库', '已出库'])
"""
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)
# 1. 关键词搜索
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
query = query.filter( query = query.filter(
@ -243,23 +276,14 @@ class BuyInboundService:
) )
) )
# 2. 状态筛选
if not statuses: if not statuses:
statuses = ['在库', '借库'] statuses = ['在库', '借库']
if '已出库' in statuses: if '已出库' in statuses:
# 如果明确查已出库可以包含库存为0的
query = query.filter(StockBuy.status.in_(statuses)) query = query.filter(StockBuy.status.in_(statuses))
else: else:
# 默认查在库,必须保证库存 > 0 query = query.filter(and_(StockBuy.status.in_(statuses), StockBuy.stock_quantity > 0))
query = query.filter(
and_(
StockBuy.status.in_(statuses),
StockBuy.stock_quantity > 0
)
)
# [核心修改] 按照入库时间倒序排序 (从近到远)
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)
current_items = pagination.items current_items = pagination.items
@ -275,7 +299,6 @@ class BuyInboundService:
qty_stock = float(item.stock_quantity or 0) qty_stock = float(item.stock_quantity or 0)
qty_avail = float(item.available_quantity or 0) qty_avail = float(item.available_quantity or 0)
# [核心修改] 格式化展示日期,去掉时分秒
date_display = '' date_display = ''
if item.in_date: if item.in_date:
try: try:
@ -286,6 +309,7 @@ class BuyInboundService:
d = { d = {
'id': item.id, 'id': item.id,
'base_id': item.base_id, 'base_id': item.base_id,
# 确保这里从关联的 MaterialBase 获取规格型号
'material_name': item.material.name if item.material else '', 'material_name': item.material.name if item.material else '',
'spec_model': item.material.spec_model if item.material else '', 'spec_model': item.material.spec_model if item.material else '',
'category': item.material.category if item.material else '', 'category': item.material.category if item.material else '',
@ -293,21 +317,15 @@ class BuyInboundService:
'material_type': item.material.material_type if item.material else '', 'material_type': item.material.material_type if item.material else '',
'sku': item.sku, 'sku': item.sku,
'inbound_date': date_display, # 前端展示用的日期字符串 'inbound_date': date_display,
'barcode': item.barcode, 'barcode': item.barcode,
'serial_number': item.serial_number, 'serial_number': item.serial_number,
'batch_number': item.batch_number, 'batch_number': item.batch_number,
'status': item.status, 'status': item.status,
'inspection_status': item.inspection_status, 'inspection_status': item.inspection_status,
'qty_inbound': float(item.in_quantity or 0), 'qty_inbound': float(item.in_quantity or 0),
'qty_stock': qty_stock, 'qty_stock': qty_stock,
'qty_available': qty_avail, 'qty_available': qty_avail,
'sum_stock': qty_stock,
'sum_available': qty_avail,
'warehouse_loc': item.warehouse_location, 'warehouse_loc': item.warehouse_location,
'unit_price': float(item.unit_price or 0), 'unit_price': float(item.unit_price or 0),
'total_price': float(item.total_price or 0), 'total_price': float(item.total_price or 0),
@ -320,8 +338,7 @@ class BuyInboundService:
'detail_link': item.detail_link, 'detail_link': item.detail_link,
'arrival_photo': parse_img(item.arrival_photo), 'arrival_photo': parse_img(item.arrival_photo),
'inspection_report': parse_img(item.inspection_report), 'inspection_report': parse_img(item.inspection_report),
'global_print_id': item.global_print_id, 'global_print_id': item.global_print_id
'global_print_id_str': f"{item.global_print_id:08d}" if item.global_print_id else ""
} }
items.append(d) items.append(d)

View File

@ -11,7 +11,30 @@ import json
class ProductInboundService: class ProductInboundService:
# ============================================================ # ============================================================
# 1. 基础物料搜索 (已修正:完全对齐 Buy/Semi 的逻辑) # 0. 辅助:唯一性校验 (新增核心逻辑)
# ============================================================
@staticmethod
def _check_unique(serial_number, exclude_id=None):
"""
校验成品的唯一性
:param serial_number: 序列号
:param exclude_id: 排除的ID (编辑模式用)
"""
from app.models.inbound.product import StockProduct
# 成品强校验序列号 (SN) - SN应该是全局唯一的
if serial_number:
query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
if exclude_id:
query = query.filter(StockProduct.id != exclude_id)
exists = query.first()
if exists:
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
# ============================================================
# 1. 基础物料搜索
# ============================================================ # ============================================================
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword):
@ -31,16 +54,16 @@ class ProductInboundService:
# 3. 排序与限制按ID倒序取最新20条 # 3. 排序与限制按ID倒序取最新20条
query = query.order_by(MaterialBase.id.desc()).limit(20) query = query.order_by(MaterialBase.id.desc()).limit(20)
# 4. 结果封装:确保字段名与前端 Vue 的 handleSelect 方法一致 # 4. 结果封装
results = [] results = []
for item in query.all(): for item in query.all():
results.append({ results.append({
'id': item.id, 'id': item.id,
'name': item.name, 'name': item.name,
'spec': item.spec_model, # 对应前端: item.spec 'spec': item.spec_model,
'category': item.category, # 对应前端: item.category 'category': item.category,
'unit': item.unit, # 对应前端: item.unit 'unit': item.unit,
'type': item.material_type, # 对应前端: item.type 'type': item.material_type,
'status': '启用' 'status': '启用'
}) })
return results return results
@ -49,7 +72,7 @@ class ProductInboundService:
return [] return []
# ============================================================ # ============================================================
# 2. 新增入库逻辑 (强制北京时间) # 2. 新增入库逻辑 (强制北京时间 + 唯一性校验)
# ============================================================ # ============================================================
@staticmethod @staticmethod
def handle_inbound(data): def handle_inbound(data):
@ -61,6 +84,11 @@ class ProductInboundService:
material = MaterialBase.query.get(base_id) material = MaterialBase.query.get(base_id)
if not material: raise ValueError("物料不存在") if not material: raise ValueError("物料不存在")
# --- [核心修改] 执行唯一性校验 ---
ProductInboundService._check_unique(
serial_number=data.get('serial_number')
)
# [核心修改] 强制北京时间 # [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8)) beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = datetime.now(beijing_tz).replace(tzinfo=None)
@ -86,11 +114,14 @@ class ProductInboundService:
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
# 全局流水号 # 全局流水号
seq_sql = text("SELECT nextval('global_print_seq')") try:
result = db.session.execute(seq_sql) seq_sql = text("SELECT nextval('global_print_seq')")
next_global_id = result.scalar() result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except:
next_global_id = None
generated_sku = str(next_global_id).zfill(10) generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku final_barcode = data.get('barcode') or generated_sku
photo_list = data.get('product_photo', []) photo_list = data.get('product_photo', [])
@ -156,6 +187,13 @@ class ProductInboundService:
stock = StockProduct.query.get(stock_id) stock = StockProduct.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
if 'serial_number' in data:
ProductInboundService._check_unique(
serial_number=data['serial_number'],
exclude_id=stock_id
)
fields = [ fields = [
'barcode', 'serial_number', 'warehouse_location', 'barcode', 'serial_number', 'warehouse_location',
'status', 'quality_status', 'bom_code', 'bom_version', 'status', 'quality_status', 'bom_code', 'bom_version',

View File

@ -11,7 +11,44 @@ import json
class SemiInboundService: class SemiInboundService:
# ============================================================ # ============================================================
# 1. 基础物料搜索 (已修复:支持空关键词返回最新数据) # 0. 辅助:唯一性校验 (新增核心逻辑)
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
"""
校验半成品的唯一性
:param base_id: 基础物料ID
:param serial_number: 序列号
:param batch_number: 批号
:param exclude_id: 排除的ID
"""
from app.models.inbound.semi import StockSemi
# 1. 序列号 (SN) 校验 - 全局唯一
if serial_number:
query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
exists = query.first()
if exists:
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
if batch_number and base_id:
query = StockSemi.query.filter(
StockSemi.base_id == base_id,
StockSemi.batch_number == batch_number
)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================ # ============================================================
@staticmethod @staticmethod
def search_base_material(keyword): def search_base_material(keyword):
@ -63,6 +100,13 @@ class SemiInboundService:
if not material: if not material:
raise ValueError(f"ID为 {base_id} 的基础物料不存在") raise ValueError(f"ID为 {base_id} 的基础物料不存在")
# --- [核心修改] 执行唯一性校验 ---
SemiInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number')
)
# [核心修改] 强制北京时间 # [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8)) beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None) current_time = datetime.now(beijing_tz).replace(tzinfo=None)
@ -194,6 +238,18 @@ class SemiInboundService:
if not stock: if not stock:
raise ValueError("记录不存在") raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number)
SemiInboundService._check_unique(
base_id=new_base_id,
serial_number=new_sn,
batch_number=new_bn,
exclude_id=stock_id
)
field_mapping = { field_mapping = {
'sku': 'sku', 'sku': 'sku',
'barcode': 'barcode', 'barcode': 'barcode',

View File

@ -0,0 +1,185 @@
import uuid
from datetime import datetime
from app.extensions import db
from app.models.transaction import TransBorrow
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy import desc, func
class TransService:
@staticmethod
def generate_borrow_no():
"""
生成借用单号: BOR-yyyyMMdd-0001 (按日流水)
逻辑:统计当天已存在的不同借用单号数量,+1 作为新序号
"""
now = datetime.now()
date_str = now.strftime('%Y%m%d')
prefix = f"BOR-{date_str}-"
# 使用 count distinct 来计算当天有多少个不同的借用单 (因为一单多货会占多行)
count = db.session.query(func.count(func.distinct(TransBorrow.borrow_no))) \
.filter(TransBorrow.borrow_no.like(f"{prefix}%")).scalar()
sequence = count + 1
return f"{prefix}{sequence:04d}"
@staticmethod
def create_borrow(data, operator_name='System'):
"""
借库逻辑:减少可用库存,不减总库存
"""
items = data.get('items', [])
borrower_name = data.get('borrower_name')
signature = data.get('signature_path') # 借用人签字
if not items: raise ValueError("物品列表为空")
if not borrower_name: raise ValueError("请输入借用人")
if not signature: raise ValueError("借用人必须签字")
borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
try:
for item in items:
source_table = item.get('source_table')
stock_id = item.get('id')
qty = float(item.get('out_quantity', 0))
ModelClass = model_map.get(source_table)
if not ModelClass: continue
stock = ModelClass.query.with_for_update().get(stock_id)
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
if float(stock.available_quantity) < qty:
raise ValueError(f"SKU {stock.sku} 可用库存不足")
# 1. 冻结库存 (只减可用)
stock.available_quantity = float(stock.available_quantity) - qty
# 2. 创建借用单
record = TransBorrow(
borrow_no=borrow_no,
sku=stock.sku,
source_table=source_table,
stock_id=stock.id,
barcode=stock.barcode,
quantity=qty,
borrower_name=borrower_name,
borrow_signature=signature,
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time'),
status='borrowed',
is_returned=False
)
db.session.add(record)
db.session.commit()
return borrow_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def scan_for_return(barcode):
"""
扫码还库:查找未归还记录,并返回当前物品的库位
"""
records = TransBorrow.query.filter_by(barcode=barcode, is_returned=False).all()
if not records:
return None
# 取第一条未还记录
record = records[0]
# 获取当前库存表中的实时库位
current_location = ""
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.get(record.stock_id)
if stock:
current_location = stock.warehouse_location
res_dict = record.to_dict()
res_dict['current_location'] = current_location # 用于前端对比和预填
return res_dict
@staticmethod
def process_return(data, operator_name):
"""
还库逻辑:
1. 恢复可用库存
2. 更新库位 (如果有变动)
3. 记录库管签字
"""
items = data.get('items', [])
signature = data.get('signature_path') # 库管签字
if not items: raise ValueError("还库列表为空")
if not signature: raise ValueError("库管必须签字确认")
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
try:
for item in items:
borrow_id = item.get('id')
# 前端如果没有填 return_location应该在提交前处理好或者这里做 fallback
# 这里假设前端传来的 return_location 就是最终要保存的库位
final_location = item.get('return_location')
record = TransBorrow.query.with_for_update().get(borrow_id)
if not record or record.is_returned:
continue
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.with_for_update().get(record.stock_id)
if stock:
# 1. 恢复可用库存
stock.available_quantity = float(stock.available_quantity) + float(record.quantity)
# 2. 更新库位 (如果提供了有效值)
if final_location:
stock.warehouse_location = final_location
# 3. 更新借用单状态
record.is_returned = True
record.status = 'returned'
record.return_time = datetime.now()
record.return_operator = operator_name
record.return_signature = signature
record.return_location = final_location
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_records(page=1, limit=10, status='all', keyword=None):
q = TransBorrow.query
if status == 'borrowed':
q = q.filter(TransBorrow.is_returned == False)
elif status == 'returned':
q = q.filter(TransBorrow.is_returned == True)
if keyword:
q = q.filter(TransBorrow.borrower_name.ilike(f'%{keyword}%') |
TransBorrow.sku.ilike(f'%{keyword}%') |
TransBorrow.borrow_no.ilike(f'%{keyword}%'))
q = q.order_by(desc(TransBorrow.borrow_time))
pagination = q.paginate(page=page, per_page=limit, error_out=False)
return {
'items': [r.to_dict() for r in pagination.items],
'total': pagination.total,
'page': page,
'limit': limit
}

View File

@ -123,7 +123,7 @@ const routes: Array<RouteRecordRaw> = [
{ {
path: '/operation', path: '/operation',
component: Layout, component: Layout,
meta: { title: '其他业务', icon: 'Operation' }, meta: { title: '借库管理', icon: 'Operation' },
redirect: '/operation/borrow', redirect: '/operation/borrow',
children: [ children: [
{ {
@ -136,13 +136,13 @@ const routes: Array<RouteRecordRaw> = [
path: 'repair', path: 'repair',
name: 'OpRepair', name: 'OpRepair',
component: () => import('@/views/transaction/return.vue'), component: () => import('@/views/transaction/return.vue'),
meta: { title: '维修' } meta: { title: '返还' }
}, },
{ {
path: 'scrap', path: 'records',
name: 'OpScrap', name: 'OpRecords',
component: () => import('@/views/transaction/scrap.vue'), component: () => import('@/views/transaction/records.vue'),
meta: { title: '报废' } meta: { title: '借还记录' }
} }
] ]
}, },

View File

@ -92,20 +92,19 @@
<span v-else class="text-placeholder">-</span> <span v-else class="text-placeholder">-</span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'"> <template #default="scope" v-else-if="col.prop === 'status'">
<el-tag :type="getStatusType(scope.row.status)" effect="light" round> <el-tag :type="getStatusType(scope.row.status)" effect="light" round>
{{ scope.row.status }} {{ scope.row.status }}
</el-tag> </el-tag>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</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
@ -128,12 +127,8 @@
</template> </template>
<template #default="scope" v-else-if="col.prop.includes('link')"> <template #default="scope" v-else-if="col.prop.includes('link')">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" <el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
:underline="false"> <el-icon><Link/></el-icon> 查看
<el-icon>
<Link/>
</el-icon>
查看
</el-link> </el-link>
</template> </template>
@ -146,10 +141,7 @@
<el-table-column label="操作" width="220" fixed="right" align="center"> <el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)"> <el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon> <el-icon><Printer/></el-icon> 打印
<Printer/>
</el-icon>
打印
</el-button> </el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220"> <el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
@ -234,10 +226,8 @@
<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.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.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.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.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-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> </el-row>
@ -273,7 +263,7 @@
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button> <el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button> <el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
</el-radio-group> </el-radio-group>
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 历史锁定</span> <span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 历史锁定 (同物料遵循历史模式)</span>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
@ -385,7 +375,9 @@
</el-dialog> </el-dialog>
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/> <input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog> <el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
@ -455,23 +447,20 @@ const inspection_report_url = ref('')
// 基础列 // 基础列
const baseColumns = [ const baseColumns = [
{prop: 'material_name', label: '名称'}, {prop: 'material_name', label: '名称'},
{prop: 'material_type', label: '类型'}, // 移到类别前面 {prop: 'material_type', label: '类型'},
{prop: 'category', label: '类别'}, {prop: 'category', label: '类别'},
{prop: 'spec_model', label: '规格型号'}, {prop: 'spec_model', label: '规格型号'},
{prop: 'unit', label: '单位'}, {prop: 'unit', label: '单位'},
] ]
// [修改] 库存与商务列配置:将序列号/批号改为 "序列号/批号" // 库存与商务列
const stockColumns = [ const stockColumns = [
{prop: 'id', label: 'ID', minWidth: '60'}, {prop: 'id', label: 'ID', minWidth: '60'},
{prop: 'base_id', label: 'BaseID', minWidth: '80'}, {prop: 'base_id', label: 'BaseID', minWidth: '80'},
{prop: 'sku', label: 'SKU', minWidth: '120'}, {prop: 'sku', label: 'SKU', minWidth: '120'},
{prop: 'inbound_date', label: '入库日期', minWidth: '120'}, {prop: 'inbound_date', label: '入库日期', minWidth: '120'},
{prop: 'barcode', label: '条码', minWidth: '120'}, {prop: 'barcode', label: '条码', minWidth: '120'},
// 新的合并列,修改 label 为 "序列号/批号"
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'}, {prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
{prop: 'status', label: '状态', minWidth: '100'}, {prop: 'status', label: '状态', minWidth: '100'},
{prop: 'inspection_status', label: '到检', minWidth: '100'}, {prop: 'inspection_status', label: '到检', minWidth: '100'},
{prop: 'qty_inbound', label: '入库量', minWidth: '100'}, {prop: 'qty_inbound', label: '入库量', minWidth: '100'},
@ -492,11 +481,8 @@ const stockColumns = [
] ]
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
// [修改] 更新 key 以强制用户获取新默认值
const STORAGE_KEY_COLS = 'stock_buy_visible_columns_v2' const STORAGE_KEY_COLS = 'stock_buy_visible_columns_v2'
// [修改] 默认列配置:加入 'sn_bn' 和 'warehouse_loc'
// 同时这里也要对应上方的顺序变化,先 material_type 后 category
const defaultColumns = [ const defaultColumns = [
'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',
@ -516,7 +502,7 @@ const form = reactive({
arrival_photo: [] as string[], inspection_report: [] as string[] arrival_photo: [] as string[], inspection_report: [] as string[]
}) })
// ... (以下逻辑保持不变) // 历史记录辅助函数
const HISTORY_KEYS = { SUPPLIER: 'history_suppliers', PURCHASER: 'history_purchasers', EMAIL: 'history_emails', MATERIAL: 'history_materials' } const HISTORY_KEYS = { SUPPLIER: 'history_suppliers', PURCHASER: 'history_purchasers', EMAIL: 'history_emails', MATERIAL: 'history_materials' }
const saveToHistory = (key: string, value: string) => { const saveToHistory = (key: string, value: string) => {
if (!value) return if (!value) return
@ -595,15 +581,19 @@ const onMaterialSelected = (val: number) => {
} }
} }
// ------------------------------------
// 校验规则
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => { const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback() if (!value) return callback()
// 前端仅做当前页面的简单重复提示,真正的校验在后端
const isDuplicate = tableData.value.some((row: any) => { const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true if (rule.field === 'serial_number' && row.serial_number === value) return true
if (rule.field === 'batch_number' && row.batch_number === value) return true if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
return false return false
}) })
if (isDuplicate) callback(new Error('编号重复')) if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
else callback() else callback()
} }
const validateIdentity = (rule: any, value: any, callback: any) => { const validateIdentity = (rule: any, value: any, callback: any) => {
@ -618,26 +608,54 @@ const rules = {
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}] batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
} }
// 自动计算批号逻辑
const checkHistoryAndSetMode = async (baseId: number) => { const checkHistoryAndSetMode = async (baseId: number) => {
try { try {
const res: any = await getBuyList({page: 1, pageSize: 1000}) const res: any = await getBuyList({page: 1, pageSize: 1000}) // 获取最近数据
const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId) const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId)
if (historyItems.length > 0) { if (historyItems.length > 0) {
modeLocked.value = true modeLocked.value = true
// 找最新的那条记录
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0] const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
if (latest.serial_number) { entryMode.value = 'serial'; form.serial_number = ''; form.batch_number = '' } if (latest.serial_number) {
else { entryMode.value = 'batch'; form.serial_number = ''; form.batch_number = incrementBatchNumber(latest.batch_number || '000000') } entryMode.value = 'serial'
} else { modeLocked.value = false; entryMode.value = 'batch'; form.batch_number = '000001' } form.serial_number = ''
if (formRef.value) { formRef.value.clearValidate('serial_number'); formRef.value.clearValidate('batch_number') } form.batch_number = ''
} catch (e) { modeLocked.value = false; entryMode.value = 'batch'; form.batch_number = '000001' } } else {
entryMode.value = 'batch'
form.serial_number = ''
// 自动递增批号
form.batch_number = incrementBatchNumber(latest.batch_number || '000000')
}
} else {
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = '000001'
}
if (formRef.value) {
formRef.value.clearValidate('serial_number')
formRef.value.clearValidate('batch_number')
}
} catch (e) {
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = '000001'
}
} }
const incrementBatchNumber = (batchStr: string) => { const incrementBatchNumber = (batchStr: string) => {
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001' if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
return (parseInt(batchStr, 10) + 1).toString().padStart(6, '0') return (parseInt(batchStr, 10) + 1).toString().padStart(6, '0')
} }
const handleEntryModeChange = (val: string) => { const handleEntryModeChange = (val: string) => {
if (val === 'batch') { form.serial_number = ''; form.batch_number = '000001'; if(formRef.value) formRef.value.clearValidate('serial_number') } if (val === 'batch') {
else { form.batch_number = ''; if(formRef.value) formRef.value.clearValidate('batch_number') } form.serial_number = ''
form.batch_number = '000001'
if(formRef.value) formRef.value.clearValidate('serial_number')
} else {
form.batch_number = ''
if(formRef.value) formRef.value.clearValidate('batch_number')
}
} }
watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) }) watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) })
@ -688,6 +706,46 @@ const handleUpdate = (row: any) => {
visible.value = true visible.value = true
} }
// ------------------------------------
// 提交逻辑
// ------------------------------------
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitting.value = true
const finalReportList = [...form.inspection_report]
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
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) }
try {
if (dialogStatus.value === 'create') {
const res: any = await createBuyInbound(payload)
ElMessage.success('入库成功')
if (res.data) {
ElMessage.info('发送打印指令...')
try { await executePrint(res.data); ElMessage.success('打印指令已发送') }
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
}
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
// 成功后保存历史记录
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name)
saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser)
saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
await fetchData()
visible.value = false
} catch (e: any) {
// 重点:捕获后端唯一性校验错误
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
}
})
}
// 其他辅助函数保持不变
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) } const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') } const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) } const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
@ -740,33 +798,6 @@ const handleCameraFile = async (event: Event) => {
} catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' } } catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' }
} }
} }
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitting.value = true
const finalReportList = [...form.inspection_report]
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
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) }
try {
if (dialogStatus.value === 'create') {
const res: any = await createBuyInbound(payload)
ElMessage.success('入库成功')
if (res.data) {
ElMessage.info('发送打印指令...')
try { await executePrint(res.data); ElMessage.success('打印指令已发送') }
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
}
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name); saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser); saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
await fetchData(); visible.value = false
} catch (e: any) { ElMessage.error(e.msg || '操作失败') } finally { submitting.value = false }
}
})
}
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 = ''
@ -786,6 +817,7 @@ onMounted(() => fetchData())
</script> </script>
<style scoped> <style scoped>
/* 样式部分保持不变,直接复用原有代码 */
.buy-module { background: #f5f7fa; padding: 20px; min-height: 100vh; } .buy-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); } .header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; } .left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }

View File

@ -425,9 +425,24 @@ const form = reactive({
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: '' quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
}) })
// ------------------------------------
// 校验规则 (前端 pre-check)
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback()
// 简单的列表前端查重
const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true
return false
})
if (isDuplicate) callback(new Error('当前列表页存在相同SN(后端将进行全局校验)'))
else callback()
}
const rules = { const rules = {
base_id: [{ required: true, message: '必选', trigger: 'change' }], base_id: [{ required: true, message: '必选', trigger: 'change' }],
serial_number: [{ required: true, message: '必填', trigger: 'blur' }], serial_number: [{ required: true, message: '必填', trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }] in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
} }
@ -585,7 +600,10 @@ const submitForm = async () => {
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager) saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
visible.value = false; fetchData() visible.value = false; fetchData()
} catch(e:any) { ElMessage.error(e.msg || '失败') } finally { submitting.value = false } } catch(e:any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
} }
}) })
} }

View File

@ -611,10 +611,11 @@ const validateUnique = (rule: any, value: string, callback: any) => {
const isDuplicate = tableData.value.some((row: any) => { const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true if (rule.field === 'serial_number' && row.serial_number === value) return true
if (rule.field === 'batch_number' && row.batch_number === value) return true // 批号校验需要同时匹配物料
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
return false return false
}) })
if (isDuplicate) callback(new Error('编号重复')) if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
else callback() else callback()
} }
const validateIdentity = (rule: any, value: any, callback: any) => { const validateIdentity = (rule: any, value: any, callback: any) => {
@ -778,7 +779,10 @@ const submitForm = async () => {
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') } } else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager) saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
await fetchData(); visible.value = false await fetchData(); visible.value = false
} catch (e: any) { ElMessage.error(e.msg || '操作失败') } finally { submitting.value = false } } catch (e: any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
} }
}) })
} }
@ -822,12 +826,8 @@ onMounted(() => fetchData())
.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: 20px; } .card-content { padding: 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; }
.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; }
.inbound-card { border-left: 4px solid #67C23A; } .inbound-card { border-left: 4px solid #67C23A; }
.production-card { border-left: 4px solid #E6A23C; } .production-card { border-left: 4px solid #E6A23C; } .production-card .card-title .icon { color: #E6A23C; }
.production-card .card-title .icon { color: #E6A23C; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 15px; margin-bottom: 20px; } .identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 15px; margin-bottom: 20px; }
.custom-radio-group { margin-bottom: 10px; } .custom-radio-group { margin-bottom: 10px; }
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; } .locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
@ -839,16 +839,12 @@ onMounted(() => fetchData())
.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;} .option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; } .opt-name { font-weight: bold; }
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; } .opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; } .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; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; } .is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
.empty-preview { color: #909399; } .search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.more-images-badge { margin-left: 5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 6px; font-size: 12px; }
.clickable-text { color: #409EFF; cursor: pointer; font-weight: 500; text-decoration: underline; }
.clickable-text:hover { color: #66b1ff; }
/* Scroll container specific to Product style */
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; } .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; }

View File

@ -1 +1,507 @@
<template><div style="padding:20px;"><h2>借库申请</h2></div></template> <template>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>借库作业 (领用人签字)</span>
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
已选 {{ cartItems.length }}
</el-tag>
</div>
</div>
</template>
<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">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启扫码</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput">添加</el-button>
</template>
</el-input>
</div>
</div>
<div class="cart-section">
<div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="可用库存" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="借用数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.out_quantity"
:min="1"
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无物品,请扫码借出" :image-size="80" />
</div>
<div v-if="cartItems.length > 0" class="form-section">
<el-divider content-position="left">借用登记信息</el-divider>
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
<el-row :gutter="15">
<el-col :span="24">
<el-form-item label="领用人/借用人" prop="borrower_name">
<el-input v-model="form.borrower_name" placeholder="请输入姓名" size="large" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="预计归还日期" prop="expected_return_time">
<el-date-picker
v-model="form.expected_return_time"
type="date"
placeholder="请选择日期"
style="width: 100%"
size="large"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="用途说明..." />
</el-form-item>
<el-form-item label="领用人签名确认" required>
<div class="signature-box" @click="openSignatureDialog">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
</div>
<div v-else class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>点击此处进行全屏签名</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出
</el-button>
</div>
</el-form>
</div>
</el-card>
<el-dialog
v-model="showSignatureDialog"
fullscreen
destroy-on-close
:show-close="false"
class="fullscreen-signature-dialog"
@opened="initCanvas"
>
<div class="signature-wrapper">
<div class="signature-canvas-container" ref="canvasContainerRef">
<canvas
ref="nativeCanvasRef"
class="native-canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
></canvas>
<div class="canvas-tip">请在此区域横屏书写</div>
</div>
<div class="signature-sidebar">
<div class="sidebar-title">电子签名</div>
<div class="sidebar-actions">
<el-button type="warning" @click="clearCanvas">重写</el-button>
<el-button @click="handleSignCancel">取消</el-button>
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload'
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
const signatureFile = ref<File | null>(null)
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const form = reactive({
borrower_name: '',
expected_return_time: '',
remark: ''
})
// ★ 修改点:增强校验规则
const rules = {
borrower_name: [
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
],
expected_return_time: [
{ required: true, message: '请选择预计归还日期', trigger: 'change' }
]
}
// ★ 新增:禁止选择今天之前的日期
const disabledDate = (time: Date) => {
return time.getTime() < Date.now() - 8.64e7 // 禁止选择昨天及之前
}
// --- 核心扫码逻辑 ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
if (loading.value) return
barcodeInput.value = trimCode
handleManualInput()
}
const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
try {
loading.value = true
// 查重
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
}
barcodeInput.value = ''
return
}
// 查库
const res = await getStockByBarcode(code)
if (res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足 (余: ${availQty})`)
} else {
cartItems.value.push({
...item,
out_quantity: 1,
price: 0
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
}
barcodeInput.value = ''
}
} catch (error: any) {
if (error.response && error.response.status === 404) {
ElMessage.error(`未找到条码: ${code}`)
} else {
ElMessage.error('查询出错')
}
} finally {
loading.value = false
nextTick(() => { barcodeRef.value?.focus() })
}
}
const removeFromCart = (index: number) => {
cartItems.value.splice(index, 1)
}
const clearAll = () => {
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
form.borrower_name = ''
form.remark = ''
form.expected_return_time = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
// ★ 核心修改:等待校验通过后再提交,否则报错会被拦截在前端
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请填写完整的必填项(姓名、归还日期)')
return
}
if (!signatureFile.value) {
ElMessage.error('请领用人进行电子签名')
return
}
try {
loading.value = true
// 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
await request({
url: '/v1/transactions/borrow',
method: 'post',
data: {
items: cartItems.value,
...form, // 此时 form.expected_return_time 已经是 YYYY-MM-DD 格式
signature_path: signatureUrl
}
})
ElMessage.success('借用成功')
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false
} catch (error: any) {
console.error(error)
ElMessage.error(error.response?.data?.msg || '提交失败')
} finally {
loading.value = false
}
})
}
// --- 签名逻辑 ---
const openSignatureDialog = () => { showSignatureDialog.value = true }
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
}
}
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
return { x: clientX - rect.left, y: clientY - rect.top }
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x; lastY.value = y
ctx.value?.beginPath()
ctx.value?.moveTo(x, y)
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
const stopDrawing = () => { isDrawing.value = false }
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
nativeCanvasRef.value?.toBlob((blob) => {
if (blob) {
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
}
}, 'image/png')
}
const handleSignCancel = () => { showSignatureDialog.value = false }
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
})
</script>
<style scoped>
.app-container.mobile-optimized {
padding: 10px; max-width: 600px; margin: 0 auto;
}
/* 头部 */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
/* 扫码区 */
.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 {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
}
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.signature-box {
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
}
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
/* 全屏签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
.signature-wrapper { display: flex; width: 100%; height: 100%; }
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.canvas-tip {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
}
.signature-sidebar {
width: 120px; background: #333; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
}
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
@media screen and (max-width: 768px) {
.signature-wrapper { flex-direction: column; }
.signature-canvas-container { flex: 1; }
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
.sidebar-title { display: none; }
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<div class="app-container">
<div class="filter-container">
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
<el-input v-model="keyword" placeholder="搜索借用人/SKU" style="width: 200px" @keyup.enter="fetchData" />
<el-button type="primary" @click="fetchData">查询</el-button>
</div>
<el-table
:data="list"
border
stripe
style="margin-top:20px"
v-loading="loading"
:row-class-name="tableRowClassName"
>
<el-table-column prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column prop="borrower_name" label="借用人" width="100" />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column label="归还时间 / 预计" min-width="200">
<template #default="{row}">
<div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag>
{{ row.return_time || '-' }}
</div>
<div v-else>
<el-tag type="info" size="small">预计</el-tag>
{{ formatExpectedTime(row.expected_return_time).text }}
<span :class="formatExpectedTime(row.expected_return_time).cssClass">
{{ formatExpectedTime(row.expected_return_time).diffText }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{row}">
<el-tag :type="row.status==='returned'?'success':'warning'">
{{ row.status==='returned'?'已还':'借出中' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="电子签名" width="140" align="center">
<template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature" width="220">
<template #reference><el-tag size="small"></el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature" width="220">
<template #reference><el-tag type="success" size="small"></el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="total"
@current-change="handlePage"
style="margin-top:10px; text-align:right"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import request from '@/utils/request'
import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date
import 'dayjs/locale/zh-cn' // 导入中文包
dayjs.locale('zh-cn')
const list = ref<any[]>([])
const total = ref(0)
// ★ 修改点:默认状态改为 'borrowed' (未归还)
const status = ref('borrowed')
const keyword = ref('')
const page = ref(1)
const loading = ref(false)
const fetchData = async () => {
loading.value = true
try {
const res = await request({
url: '/v1/transactions/records',
method: 'get',
params: {
page: page.value,
status: status.value,
keyword: keyword.value
}
})
list.value = res.data.items
total.value = res.data.total
} finally { loading.value = false }
}
const handlePage = (val: number) => {
page.value = val
fetchData()
}
// ★ 新增:格式化预计归还时间及倒计时逻辑
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '-', diffText: '', cssClass: '' }
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss我们只取日期部分比较
const expected = dayjs(timeStr).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
let diffText = ''
let cssClass = ''
// 这里的 timeStr 只展示前10位 (日期),或者展示完整
// 需求说单号规则是日期,预计归还也主要看日期
const displayTime = timeStr.substring(0, 10)
if (diffDays < 0) {
// 逾期
diffText = ` (逾期 ${Math.abs(diffDays)} 天)`
cssClass = 'text-danger'
} else if (diffDays === 0) {
// 今天到期
diffText = ` (今天到期)`
cssClass = 'text-warning'
} else {
// 剩余
diffText = ` (剩 ${diffDays} 天)`
cssClass = 'text-normal' // 正常,或者灰色
}
return { text: displayTime, diffText, cssClass }
}
// ★ 新增:表格行样式逻辑
const tableRowClassName = ({ row }: { row: any }) => {
// 如果已归还,不标颜色
if (row.status === 'returned') return ''
if (!row.expected_return_time) return ''
const expected = dayjs(row.expected_return_time).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
if (diffDays < 0) {
return 'danger-row' // 逾期标红
} else if (diffDays === 0) {
return 'warning-row' // 当天标黄
}
return ''
}
onMounted(fetchData)
</script>
<style>
/* 注意Element Plus Table 的 row-class-name 样式通常不能放在 scoped 中 */
.el-table .warning-row {
--el-table-tr-bg-color: #fdf6ec !important; /* 浅橙色/黄色 */
}
.el-table .danger-row {
--el-table-tr-bg-color: #fef0f0 !important; /* 浅红色 */
color: #F56C6C; /* 文字变红增强警示 */
}
/* 文字颜色辅助类 */
.text-danger {
color: #F56C6C;
font-weight: bold;
}
.text-warning {
color: #E6A23C;
font-weight: bold;
}
.text-normal {
color: #909399;
}
</style>

View File

@ -1 +1,447 @@
<template><div style="padding:20px;"><h2>维修登记</h2></div></template> <template>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>还库作业 (库管签字)</span>
<el-tag v-if="returnList.length > 0" type="success" size="small" effect="dark">
待还 {{ returnList.length }}
</el-tag>
</div>
</div>
</template>
<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">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启扫码</span>
</div>
<div class="input-box">
<el-input
v-model="barcode"
placeholder="扫描已借出物品条码"
@keyup.enter="scanItem"
clearable
ref="barcodeRef"
size="large"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="scanItem">识别</el-button>
</template>
</el-input>
</div>
</div>
<div class="cart-section">
<div v-if="returnList.length > 0">
<el-table :data="returnList" border stripe style="width: 100%">
<el-table-column prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="归还库位(可改)" min-width="160">
<template #default="{row}">
<el-input
v-model="row.return_location"
:placeholder="`原: ${row.current_location || '无'}`"
clearable
size="small"
>
<template #append v-if="row.return_location !== row.current_location">
<span style="color: #E6A23C; font-size: 12px;">变更</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="请扫描已借出的条码" :image-size="80" />
</div>
<div v-if="returnList.length > 0" class="form-section">
<el-divider content-position="left">还库确认</el-divider>
<div style="margin-bottom: 10px; font-size: 14px; color: #606266; padding: 0 10px;">
请库管员在此签字确认入库
</div>
<el-form label-position="top">
<el-form-item required>
<div class="signature-box" @click="openSignatureDialog">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
</div>
<div v-else class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>点击此处进行库管签名</span>
</div>
</div>
</el-form-item>
</el-form>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
确认归还
</el-button>
</div>
</div>
</el-card>
<el-dialog
v-model="showSignatureDialog"
fullscreen
destroy-on-close
:show-close="false"
class="fullscreen-signature-dialog"
@opened="initCanvas"
>
<div class="signature-wrapper">
<div class="signature-canvas-container" ref="canvasContainerRef">
<canvas
ref="nativeCanvasRef"
class="native-canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
></canvas>
<div class="canvas-tip">请在此区域横屏书写</div>
</div>
<div class="signature-sidebar">
<div class="sidebar-title">电子签名</div>
<div class="sidebar-actions">
<el-button type="warning" @click="clearCanvas">重写</el-button>
<el-button @click="handleSignCancel">取消</el-button>
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onUnmounted } from 'vue'
import request from '@/utils/request'
import { uploadFile } from '@/api/common/upload'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
// --- 状态 ---
const barcode = ref('')
const returnList = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
// 签名状态
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
const signatureFile = ref<File | null>(null)
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
// --- 扫码回调 (复刻) ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
if (loading.value) return
barcode.value = trimCode
scanItem()
}
const scanItem = async () => {
const code = barcode.value.trim()
if(!code) return
try {
loading.value = true
const res = await request({
url: '/v1/transactions/return/scan',
method: 'get',
params: { barcode: code }
})
if(returnList.value.some(i => i.id === res.data.id)) {
ElMessage.warning('已在清单中')
barcode.value = ''
return
}
const item = res.data
// 默认将归还库位填为当前库位
item.return_location = item.current_location || ''
returnList.value.push(item)
barcode.value = ''
ElMessage.success('识别成功')
} catch(e) {
ElMessage.error('未找到该物品的未还记录')
} finally {
loading.value = false
nextTick(() => { barcodeRef.value?.focus() })
}
}
const clearAll = () => {
ElMessageBox.confirm('确定清空所有待还物品吗?', '提示', { type: 'warning' })
.then(() => {
returnList.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
barcode.value = ''
})
}
// --- 提交前检查 (库位变更警告) ---
const preSubmitCheck = async () => {
if (returnList.value.length === 0) return
if (!signatureFile.value) {
ElMessage.error('库管必须签字确认')
return
}
// 1. 处理空输入:默认为原库位
returnList.value.forEach(item => {
if (!item.return_location || item.return_location.trim() === '') {
item.return_location = item.current_location
}
})
// 2. 检测变更
const changedItems = returnList.value.filter(
item => item.return_location !== item.current_location
)
// 3. 弹窗确认
if (changedItems.length > 0) {
try {
await ElMessageBox.confirm(
`检测到 ${changedItems.length} 个物品的库位发生变更。\n\n请务必打印新的标签并贴在物品上\n\n是否确认继续`,
'库位变更提醒',
{
confirmButtonText: '已知晓,确认归还',
cancelButtonText: '取消',
type: 'warning',
center: true
}
)
submitReturn()
} catch {
// 用户取消
return
}
} else {
submitReturn()
}
}
const submitReturn = async () => {
loading.value = true
try {
const upRes = await uploadFile(signatureFile.value!)
await request({
url: '/v1/transactions/return',
method: 'post',
data: {
items: returnList.value,
signature_path: upRes.data.url
}
})
ElMessage.success('还库成功')
returnList.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false // 关闭摄像头
} catch(e: any) {
ElMessage.error(e.response?.data?.msg || '提交失败')
} finally {
loading.value = false
}
}
// --- 签名逻辑 (复刻) ---
const openSignatureDialog = () => { showSignatureDialog.value = true }
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
}
}
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
return { x: clientX - rect.left, y: clientY - rect.top }
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x; lastY.value = y
ctx.value?.beginPath()
ctx.value?.moveTo(x, y)
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
const stopDrawing = () => { isDrawing.value = false }
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
nativeCanvasRef.value?.toBlob((blob) => {
if (blob) {
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
}
}, 'image/png')
}
const handleSignCancel = () => { showSignatureDialog.value = false }
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
})
</script>
<style scoped>
.app-container.mobile-optimized {
padding: 10px; max-width: 600px; margin: 0 auto;
}
/* 头部 */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
/* 扫码区 */
.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 {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
}
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.signature-box {
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
}
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
/* 全屏签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
.signature-wrapper { display: flex; width: 100%; height: 100%; }
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.canvas-tip {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
}
.signature-sidebar {
width: 120px; background: #333; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
}
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
@media screen and (max-width: 768px) {
.signature-wrapper { flex-direction: column; }
.signature-canvas-container { flex: 1; }
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
.sidebar-title { display: none; }
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>

View File

@ -1 +0,0 @@
<template><div style="padding:20px;"><h2>报废处理</h2></div></template>