成品图像上传初实现,支持多图,检测报告的图片以及链接上传

This commit is contained in:
dxc
2026-02-03 13:20:17 +08:00
parent d084bd29dd
commit 3257973820
3 changed files with 516 additions and 420 deletions

View File

@ -1,5 +1,6 @@
# app/models/inbound/product.py # app/models/inbound/product.py
from app.extensions import db from app.extensions import db
import json
class StockProduct(db.Model): class StockProduct(db.Model):
@ -17,7 +18,6 @@ class StockProduct(db.Model):
production_date = db.Column(db.Date) production_date = db.Column(db.Date)
barcode = db.Column(db.String(100)) barcode = db.Column(db.String(100))
serial_number = db.Column(db.String(100)) serial_number = db.Column(db.String(100))
# Note: 成品通常按SN管理SQL定义无 batch_number
# 数量 # 数量
in_quantity = db.Column(db.Numeric(19, 4), default=0) in_quantity = db.Column(db.Numeric(19, 4), default=0)
@ -29,27 +29,32 @@ class StockProduct(db.Model):
warehouse_location = db.Column(db.String(100)) warehouse_location = db.Column(db.String(100))
# 生产与成本 # 生产与成本
bom_code = db.Column('bom_id', db.String(100)) # 映射 SQL: bom_id bom_code = db.Column('bom_id', db.String(100))
bom_version = db.Column(db.String(50)) bom_version = db.Column(db.String(50))
work_order_code = db.Column('work_order_id', db.String(100)) # 映射 SQL: work_order_id work_order_code = db.Column('work_order_id', db.String(100))
raw_material_cost = db.Column(db.Numeric(19, 4), default=0) raw_material_cost = db.Column(db.Numeric(19, 4), default=0)
manual_cost = db.Column(db.Numeric(19, 4), default=0) manual_cost = db.Column(db.Numeric(19, 4), default=0)
production_manager = db.Column('producer_name', db.String(100)) # 映射 SQL: producer_name production_manager = db.Column('producer_name', db.String(100))
production_time_range = db.Column(db.String(255)) production_time_range = db.Column(db.String(255))
# 质量与链接 # 质量与检测 (均为 JSON 存储)
quality_status = db.Column(db.String(50)) quality_status = db.Column(db.String(50))
quality_report_link = db.Column(db.Text) quality_report_link = db.Column(db.Text) # 质量报告
detail_link = db.Column(db.Text) inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON)
# 成品特有字段 # [新增] 成品实拍图 (JSON 存储)
product_photo = db.Column(db.Text)
detail_link = db.Column(db.Text)
remark = db.Column(db.Text)
# 销售相关
sale_price = db.Column(db.Numeric(19, 4), default=0) sale_price = db.Column(db.Numeric(19, 4), default=0)
inspection_report_link = db.Column(db.Text)
order_id = db.Column(db.String(100)) order_id = db.Column(db.String(100))
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq) # 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 # 关系定义
@ -60,6 +65,17 @@ class StockProduct(db.Model):
man_val = float(self.manual_cost or 0) man_val = float(self.manual_cost or 0)
unit_total = raw_val + man_val unit_total = raw_val + man_val
# 辅助解析函数
def parse_img_list(json_str):
if not json_str:
return []
try:
if not json_str.startswith('['):
return [json_str] # 兼容旧数据单链接
return json.loads(json_str)
except:
return []
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_id, 'base_id': self.base_id,
@ -97,14 +113,18 @@ class StockProduct(db.Model):
1] if self.production_time_range and ' ~ ' in self.production_time_range else '', 1] if self.production_time_range and ' ~ ' in self.production_time_range else '',
'quality_status': self.quality_status, 'quality_status': self.quality_status,
'quality_report_link': self.quality_report_link,
# [核心修改] 三个图片/链接字段全部解析为数组
'product_photo': parse_img_list(self.product_photo),
'quality_report_link': parse_img_list(self.quality_report_link),
'inspection_report_link': parse_img_list(self.inspection_report_link),
'detail_link': self.detail_link, 'detail_link': self.detail_link,
'remark': self.remark,
'sale_price': float(self.sale_price or 0), 'sale_price': float(self.sale_price or 0),
'inspection_report_link': self.inspection_report_link,
'order_id': self.order_id, 'order_id': self.order_id,
# [新增] 返回全局打印ID及其格式化字符串
'global_print_id': self.global_print_id, 'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else "" 'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
} }

View File

@ -1,10 +1,10 @@
# app/services/inbound/product_service.py # app/services/inbound/product_service.py
from app.extensions import db from app.extensions import db
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.inbound.product import StockProduct
from datetime import datetime from datetime import datetime
from sqlalchemy import or_, func, text from sqlalchemy import or_, func, text
import traceback import traceback
import json
class ProductInboundService: class ProductInboundService:
@ -12,7 +12,6 @@ class ProductInboundService:
def search_base_material(keyword): def search_base_material(keyword):
try: try:
if not keyword: if not keyword:
# 如果没有关键词返回最新的20条
query = MaterialBase.query.filter(MaterialBase.is_enabled == True).order_by( query = MaterialBase.query.filter(MaterialBase.is_enabled == True).order_by(
MaterialBase.id.desc()).limit(20) MaterialBase.id.desc()).limit(20)
else: else:
@ -34,6 +33,8 @@ class ProductInboundService:
@staticmethod @staticmethod
def handle_inbound(data): def handle_inbound(data):
from app.models.inbound.product import StockProduct
try: try:
base_id = data.get('base_id') base_id = data.get('base_id')
if not base_id: raise ValueError("必须选择基础物料") if not base_id: raise ValueError("必须选择基础物料")
@ -43,45 +44,39 @@ class ProductInboundService:
in_date_val = datetime.utcnow().date() in_date_val = datetime.utcnow().date()
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: date_str = date_str[:10]
date_str = date_str[:10]
in_date_val = datetime.strptime(date_str, '%Y-%m-%d').date() in_date_val = datetime.strptime(date_str, '%Y-%m-%d').date()
except: except:
pass pass
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
# 处理生产时间范围
p_start = data.get('production_start_time', '') p_start = data.get('production_start_time', '')
p_end = data.get('production_end_time', '') p_end = data.get('production_end_time', '')
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
# ------------------------------------------------------------------ # 全局流水号
# 1. 获取全局打印流水号 (跨表唯一,用于打印逻辑)
# ------------------------------------------------------------------
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()
# ------------------------------------------------------------------
# 2. 自动生成 SKU (格式: 10位数字补零)
# ------------------------------------------------------------------
generated_sku = str(next_global_id).zfill(10) generated_sku = str(next_global_id).zfill(10)
final_barcode = data.get('barcode') or generated_sku
# ------------------------------------------------------------------ # [核心修改] 处理三个图片/链接列表
# 3. 条码逻辑处理 photo_list = data.get('product_photo', [])
# 如果前端没传条码,则默认使用 SKU 作为条码 quality_list = data.get('quality_report_link', [])
# ------------------------------------------------------------------ inspection_list = data.get('inspection_report_link', [])
final_barcode = data.get('barcode')
if not final_barcode: if not isinstance(photo_list, list): photo_list = []
final_barcode = generated_sku if not isinstance(quality_list, list): quality_list = []
if not isinstance(inspection_list, list): inspection_list = []
new_stock = StockProduct( new_stock = StockProduct(
base_id=material.id, base_id=material.id,
global_print_id=next_global_id, # 新增全局打印ID global_print_id=next_global_id,
sku=generated_sku, # 使用自动生成的SKU sku=generated_sku,
production_date=in_date_val, production_date=in_date_val,
barcode=final_barcode, barcode=final_barcode,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
@ -103,18 +98,21 @@ class ProductInboundService:
manual_cost=float(data.get('manual_cost') or 0), manual_cost=float(data.get('manual_cost') or 0),
quality_status=data.get('quality_status', '合格'), quality_status=data.get('quality_status', '合格'),
quality_report_link=data.get('quality_report_link'),
# 存为 JSON
product_photo=json.dumps(photo_list),
quality_report_link=json.dumps(quality_list),
inspection_report_link=json.dumps(inspection_list),
detail_link=data.get('detail_link'), detail_link=data.get('detail_link'),
remark=data.get('remark'),
sale_price=float(data.get('sale_price') or 0), sale_price=float(data.get('sale_price') or 0),
inspection_report_link=data.get('inspection_report_link'),
order_id=data.get('order_id') order_id=data.get('order_id')
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.commit() db.session.commit()
# 返回对象实例以便上层调用 to_dict()
return new_stock return new_stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@ -122,26 +120,38 @@ class ProductInboundService:
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
from app.models.inbound.product import StockProduct
try: try:
stock = StockProduct.query.get(stock_id) stock = StockProduct.query.get(stock_id)
if not stock: raise ValueError("记录不存在") if not stock: raise ValueError("记录不存在")
# 允许更新的字段列表
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',
'work_order_code', 'production_manager', 'quality_report_link', 'work_order_code', 'production_manager',
'detail_link', 'inspection_report_link', 'order_id' 'detail_link', 'order_id', 'remark'
] ]
for f in fields: for f in fields:
if f in data: setattr(stock, f, data[f]) if f in data: setattr(stock, f, data[f])
# 数值类型处理 # [核心修改] 更新 JSON 字段
if 'product_photo' in data:
imgs = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
if 'quality_report_link' in data:
imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
if 'inspection_report_link' in data:
imgs = data['inspection_report_link']
if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs)
if 'sale_price' in data: stock.sale_price = float(data['sale_price']) if 'sale_price' in data: stock.sale_price = float(data['sale_price'])
if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost']) if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost'])
if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost']) if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost'])
# 数量更新逻辑 (同步更新库存和可用量)
if 'in_quantity' in data: if 'in_quantity' in data:
new_qty = float(data['in_quantity']) new_qty = float(data['in_quantity'])
old_qty = float(stock.in_quantity) old_qty = float(stock.in_quantity)
@ -151,14 +161,11 @@ class ProductInboundService:
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff
# 时间范围处理
if 'production_start_time' in data or 'production_end_time' in data: if 'production_start_time' in data or 'production_end_time' in data:
old_range = stock.production_time_range or " ~ " old_range = stock.production_time_range or " ~ "
parts = old_range.split(' ~ ') parts = old_range.split(' ~ ')
# 获取原值防止越界
old_start = parts[0] if len(parts) > 0 else '' old_start = parts[0] if len(parts) > 0 else ''
old_end = parts[1] if len(parts) > 1 else '' old_end = parts[1] if len(parts) > 1 else ''
start = data.get('production_start_time', old_start) start = data.get('production_start_time', old_start)
end = data.get('production_end_time', old_end) end = data.get('production_end_time', old_end)
stock.production_time_range = f"{start} ~ {end}" stock.production_time_range = f"{start} ~ {end}"
@ -171,6 +178,7 @@ class ProductInboundService:
@staticmethod @staticmethod
def delete_inbound(stock_id): def delete_inbound(stock_id):
from app.models.inbound.product import StockProduct
try: try:
stock = StockProduct.query.get(stock_id) stock = StockProduct.query.get(stock_id)
if stock: if stock:
@ -183,8 +191,8 @@ class ProductInboundService:
@staticmethod @staticmethod
def get_list(page, limit, keyword=None): def get_list(page, limit, keyword=None):
from app.models.inbound.product import StockProduct
try: try:
# 联表查询
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id) query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
if keyword: if keyword:
@ -199,7 +207,6 @@ class ProductInboundService:
pagination = query.order_by(StockProduct.id.desc()).paginate(page=page, per_page=limit, error_out=False) pagination = query.order_by(StockProduct.id.desc()).paginate(page=page, per_page=limit, error_out=False)
# 计算聚合库存
current_items = pagination.items current_items = pagination.items
base_ids = list(set([i.base_id for i in current_items])) base_ids = list(set([i.base_id for i in current_items]))
stock_map = {} stock_map = {}

View File

@ -13,18 +13,38 @@
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template> <template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :value="c.prop">{{ c.label }}</el-checkbox></el-col> <el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
</el-row> </el-row>
</el-checkbox-group> </el-checkbox-group>
</el-popover> </el-popover>
</div> </div>
</div> </div>
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%" class="modern-table" header-cell-class-name="table-header-gray"> <el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
class="modern-table"
header-cell-class-name="table-header-gray"
>
<template v-for="col in allColumns" :key="col.prop"> <template v-for="col in allColumns" :key="col.prop">
<el-table-column v-if="visibleColumnProps.includes(col.prop)" :prop="col.prop" :label="col.label" :min-width="col.minWidth || '120'" show-overflow-tooltip> <el-table-column
v-if="visibleColumnProps.includes(col.prop)"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '110'"
show-overflow-tooltip
>
<template #default="scope" v-if="['serial_number'].includes(col.prop)"> <template #default="scope" v-if="col.prop === 'material_name'">
<span class="clickable-text" @click="handleUpdate(scope.row)">
{{ scope.row.material_name }}
</span>
</template>
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
<span v-if="scope.row[col.prop]" class="tag-sn">{{ scope.row[col.prop] }}</span> <span v-if="scope.row[col.prop]" class="tag-sn">{{ scope.row[col.prop] }}</span>
<span v-else class="text-placeholder">-</span> <span v-else class="text-placeholder">-</span>
</template> </template>
@ -41,7 +61,28 @@
<el-tag :type="getQualityType(scope.row.quality_status)" effect="dark" size="small">{{ scope.row.quality_status }}</el-tag> <el-tag :type="getQualityType(scope.row.quality_status)" effect="dark" size="small">{{ scope.row.quality_status }}</el-tag>
</template> </template>
<template #default="scope" v-else-if="['quality_report_link', 'detail_link', 'inspection_report_link'].includes(col.prop)"> <template #default="scope" v-else-if="['product_photo', 'quality_report_link', 'inspection_report_link'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image
style="width: 40px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6; cursor: zoom-in;"
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
>
<template #error>
<div class="image-slot"><el-icon><Picture /></el-icon></div>
</template>
</el-image>
<span v-if="getImagesOnly(scope.row[col.prop]).length > 1" class="more-images-badge">+{{getImagesOnly(scope.row[col.prop]).length}}</span>
</div>
<div v-else-if="hasExternalLink(scope.row[col.prop])" style="text-align: center;">
<el-tag size="small" type="info"><el-icon><Link /></el-icon> 链接</el-tag>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="['detail_link'].includes(col.prop)">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false"> <el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
<el-icon><Link /></el-icon> 查看 <el-icon><Link /></el-icon> 查看
</el-link> </el-link>
@ -54,10 +95,10 @@
</el-table-column> </el-table-column>
</template> </template>
<el-table-column label="操作" width="220" fixed="right" align="center"> <el-table-column label="操作" width="180" 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><Printer/></el-icon> 打印 <el-icon><Printer/></el-icon>
</el-button> </el-button>
<el-button link type="primary" @click="handleUpdate(row)">编辑</el-button> <el-button link type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm> <el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm>
@ -68,134 +109,206 @@
<el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" /> <el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog"> <el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="large" class="stylish-form"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card"> <div class="form-card basic-card">
<div class="card-title"><el-icon class="icon"><Box /></el-icon><span>1. 基础信息</span></div> <div class="card-title"><el-icon class="icon"><Box /></el-icon><span>1. 基础信息</span></div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10"> <el-col :span="10">
<el-form-item label="物料搜索" prop="base_id"> <el-form-item label="物料搜索" prop="base_id">
<el-select <el-select
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword
placeholder="搜名称/规格..." placeholder="搜名称/规格..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option
> >
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id"> <el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div class="option-item"> <div class="option-item">
<span class="opt-name">{{ item.name }}</span> <span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span> <span class="opt-spec">{{ item.spec }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag> <el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title"><el-icon class="icon"><House /></el-icon><span>2. 入库详情</span></div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
<div class="identity-panel">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="序列号(SN)" prop="serial_number">
<el-input v-model="form.serial_number" placeholder="必填: 唯一序列号" clearable><template #prefix><span class="prefix-tag sn">SN</span></template></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="24" style="margin-top:15px">
<el-col :span="6">
<el-form-item label="质量状态">
<el-select v-model="form.quality_status" style="width:100%">
<el-option label="合格" value="合格" /><el-option label="不合格" value="不合格" /><el-option label="待检" value="待检" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="18">
<el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container">
<el-upload
v-model:file-list="productPhotoList"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'product_photo')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'product_photo')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('product_photo')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div> </div>
</el-option> </div>
</el-select> <el-input v-model="form.product_photo" style="display:none" />
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料输入关键词进行精确搜索
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title"><el-icon class="icon"><House /></el-icon><span>2. 入库详情</span></div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
<div class="identity-panel">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="序列号(SN)" prop="serial_number">
<el-input v-model="form.serial_number" placeholder="必填: 唯一序列号" clearable><template #prefix><span class="prefix-tag sn">SN</span></template></el-input>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="入库数量" prop="in_quantity"> <el-form-item label="质量报告" prop="quality_report_link">
<el-input-number v-model="form.in_quantity" :min="1" style="width:100%" /> <div class="upload-container">
<el-upload
v-model:file-list="qualityFileList"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'quality_report_link')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'quality_report_link')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div>
</div>
<el-input v-model="quality_url" placeholder="外部链接..." style="margin-top:8px" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.quality_report_link" style="display:none" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container">
<el-upload
v-model:file-list="inspectionFileList"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'inspection_report_link')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report_link')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div>
</div>
<el-input v-model="inspection_url" placeholder="外部链接..." style="margin-top:8px" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.inspection_report_link" style="display:none" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
<el-row :gutter="24" style="margin-top:15px">
<el-col :span="6">
<el-form-item label="质量状态">
<el-select v-model="form.quality_status" style="width:100%">
<el-option label="合格" value="合格" /><el-option label="不合格" value="不合格" /><el-option label="待检" value="待检" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="9"><el-form-item label="质量报告"><el-input v-model="form.quality_report_link" placeholder="链接" /></el-form-item></el-col>
<el-col :span="9"><el-form-item label="检测报告"><el-input v-model="form.inspection_report_link" placeholder="产品检测报告链接" /></el-form-item></el-col>
</el-row>
</div> </div>
</div>
<div class="form-card production-card"> <div class="form-card production-card">
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div> <div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
<div class="card-content"> <div class="card-content">
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" /></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="8"><el-form-item label="订单号"><el-input v-model="form.order_id" placeholder="关联销售订单" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="订单号"><el-input v-model="form.order_id" placeholder="关联销售订单" /></el-form-item></el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="负责人"> <el-form-item label="负责人">
<el-autocomplete <el-autocomplete
v-model="form.production_manager" v-model="form.production_manager"
:fetch-suggestions="querySearchManager" :fetch-suggestions="querySearchManager"
placeholder="输入或选择负责人" placeholder="输入或选择负责人"
style="width: 100%" style="width: 100%"
clearable clearable
:trigger-on-focus="true" :trigger-on-focus="true"
@select="handleManagerSelect" @select="handleManagerSelect"
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"><el-form-item label="产品定价"><el-input-number v-model="form.sale_price" :precision="2" style="width:100%"><template #prefix>¥</template></el-input-number></el-form-item></el-col> <el-col :span="8"><el-form-item label="产品定价"><el-input-number v-model="form.sale_price" :precision="2" style="width:100%"><template #prefix>¥</template></el-input-number></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="24"> <el-row :gutter="24">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="生产时间"> <el-form-item label="生产时间">
<el-date-picker v-model="form.production_time_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /> <el-date-picker v-model="form.production_time_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"><el-form-item label="原料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" style="width:100%" /></el-form-item></el-col> <el-col :span="6"><el-form-item label="原料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="人工成本"><el-input-number v-model="form.manual_cost" :precision="2" style="width:100%" /></el-form-item></el-col> <el-col :span="6"><el-form-item label="人工成本"><el-input-number v-model="form.manual_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
</el-row> </el-row>
<el-row :gutter="24" style="margin-top:10px"> <el-row :gutter="24" style="margin-top:10px">
<el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" /></el-form-item></el-col> <el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" /></el-form-item></el-col>
</el-row> </el-row>
</div>
</div> </div>
</div> </el-form>
</el-form> </div>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@ -207,19 +320,18 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog <input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile" />
v-model="printVisible"
title="标签打印预览" <el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
width="400px" <img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
destroy-on-close </el-dialog>
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">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div> <div v-else class="empty-preview">正在生成预览...</div>
</div> </div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"> <div style="margin-top: 20px; font-size: 14px; color: #666;">
<p>打印机 IP: 192.168.9.205</p> <p>打印机 IP: 192.168.9.205</p>
<p>尺寸: 40mm x 30mm</p> <p>尺寸: 40mm x 30mm</p>
@ -240,10 +352,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer } from '@element-plus/icons-vue' import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product' import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import { getLabelPreview, executePrint } from '@/api/common/print' import { getLabelPreview, executePrint } from '@/api/common/print'
const loading = ref(false) const loading = ref(false)
@ -264,27 +377,63 @@ const printing = ref(false)
const previewUrl = ref('') const previewUrl = ref('')
const currentPrintData = ref<any>({}) const currentPrintData = ref<any>({})
// 图片/拍照相关
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
// 3个独立的列表
const productPhotoList = ref<any[]>([]) // 成品实拍
const qualityFileList = ref<any[]>([]) // 质量报告
const inspectionFileList = ref<any[]>([]) // 检测报告
const cameraInputRef = ref<HTMLInputElement | null>(null)
const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo')
const quality_url = ref('')
const inspection_url = ref('')
// [核心优化] 所有列定义
const allColumns = [ const allColumns = [
{ prop: 'material_name', label: '名称', minWidth: '120' }, { prop: 'material_name', label: '名称', minWidth: '140' },
{ prop: 'sku', label: 'SKU', minWidth: '110' },
{ prop: 'serial_number', label: '序列号', minWidth: '130' },
{ prop: 'qty_stock', label: '库存', minWidth: '90' },
{ prop: 'status', label: '状态', minWidth: '90' },
{ prop: 'quality_status', label: '质量', minWidth: '90' },
{ prop: 'spec_model', label: '规格', minWidth: '120' }, { prop: 'spec_model', label: '规格', minWidth: '120' },
{ prop: 'sku', label: 'SKU', minWidth: '100' }, { prop: 'product_photo', label: '实拍图', minWidth: '100' },
{ prop: 'serial_number', label: '序列号', minWidth: '140' },
{ prop: 'qty_stock', label: '库存', minWidth: '80' },
{ prop: 'status', label: '状态', minWidth: '80' },
{ prop: 'quality_status', label: '质量', minWidth: '80' },
{ prop: 'sale_price', label: '售价', minWidth: '100' }, { prop: 'sale_price', label: '售价', minWidth: '100' },
{ prop: 'order_id', label: '订单号', minWidth: '120' }, { prop: 'order_id', label: '订单号', minWidth: '120' },
{ prop: 'work_order_code', label: '工单号', minWidth: '120' }, { prop: 'work_order_code', label: '工单号', minWidth: '120' },
{ prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'inspection_report_link', label: '检测报告', minWidth: '100' },
{ prop: 'quality_report_link', label: '质量报告', minWidth: '100' }, { prop: 'quality_report_link', label: '质量报告', minWidth: '100' },
{ prop: 'inspection_report_link', label: '检测报告', minWidth: '100' },
{ prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'production_manager', label: '负责人', minWidth: '100' }, { prop: 'production_manager', label: '负责人', minWidth: '100' },
{ prop: 'raw_material_cost', label: '原料成本', minWidth: '100' }, { prop: 'raw_material_cost', label: '原料成本', minWidth: '100' },
{ prop: 'manual_cost', label: '人工成本', minWidth: '100' }, { prop: 'manual_cost', label: '人工成本', minWidth: '100' },
{ prop: 'inbound_date', label: '生产日期', minWidth: '120' } { prop: 'inbound_date', label: '生产日期', minWidth: '120' },
{ prop: 'detail_link', label: '详情', minWidth: '100' }
] ]
const visibleColumnProps = ref(allColumns.map(c => c.prop)) // [核心优化] 默认显示的列 (减少到核心几列,避免卡顿)
const defaultVisibleCols = [
'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status',
'product_photo', 'sale_price', 'order_id'
]
// 表头持久化
const STORAGE_KEY = 'stock_product_visible_columns'
const getSavedColumns = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
return saved ? JSON.parse(saved) : defaultVisibleCols
} catch (e) {
return defaultVisibleCols
}
}
const visibleColumnProps = ref(getSavedColumns())
watch(visibleColumnProps, (newVal) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal))
}, { deep: true })
const form = reactive({ const form = reactive({
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '',
@ -294,7 +443,10 @@ const form = reactive({
bom_code: '', bom_version: '', work_order_code: '', order_id: '', bom_code: '', bom_version: '', work_order_code: '', order_id: '',
production_manager: '', production_time_range: [] as string[], production_manager: '', production_time_range: [] as string[],
raw_material_cost: 0, manual_cost: 0, sale_price: 0, raw_material_cost: 0, manual_cost: 0, sale_price: 0,
quality_report_link: '', inspection_report_link: '', detail_link: '' quality_report_link: [] as string[],
inspection_report_link: [] as string[],
product_photo: [] as string[],
detail_link: ''
}) })
const rules = { const rules = {
@ -303,133 +455,23 @@ const rules = {
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }] in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
} }
// ------------------------------------ const HISTORY_KEYS = { PRODUCTION_MANAGER: 'history_product_managers', MATERIAL: 'history_product_materials' }
// 历史记录管理器 (Local Storage) const saveToHistory = (key: string, value: string) => { if (!value) return; try { const existing = localStorage.getItem(key); let list = existing ? JSON.parse(existing) : []; list = list.filter((i: string) => i !== value); list.unshift(value); if (list.length > 20) list = list.slice(0, 20); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
// ------------------------------------ const getHistoryList = (key: string): any[] => { try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({ value: v })) } catch (e) { return [] } }
const HISTORY_KEYS = { const saveMaterialHistory = (item: any) => { if (!item || !item.id) return; const key = HISTORY_KEYS.MATERIAL; try { let list = JSON.parse(localStorage.getItem(key) || '[]'); list = list.filter((i: any) => i.id !== item.id); list.unshift({ ...item, isHistory: true }); if (list.length > 10) list = list.slice(0, 10); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
PRODUCTION_MANAGER: 'history_product_managers', const getMaterialHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] } }
MATERIAL: 'history_product_materials'
}
// 保存历史 (String 类型) const createFilter = (qs: string) => { return (item: any) => (item.value.toLowerCase().indexOf(qs.toLowerCase()) === 0) }
const saveToHistory = (key: string, value: string) => { const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({ value: i })) }
if (!value) return const mixedSearch = (qs: string, tableField: string, storageKey: string, cb: any) => { const tableList = getTableDataUnique(tableField); const historyList = getHistoryList(storageKey); const map = new Map(); historyList.forEach(i => map.set(i.value, i)); tableList.forEach(i => map.set(i.value, i)); const allList = Array.from(map.values()); const results = qs ? allList.filter(createFilter(qs)) : allList; cb(results) }
try {
const existing = localStorage.getItem(key)
let list = existing ? JSON.parse(existing) : []
list = list.filter((i: string) => i !== value)
list.unshift(value)
if (list.length > 20) list = list.slice(0, 20)
localStorage.setItem(key, JSON.stringify(list))
} catch (e) { console.error('save history failed', e) }
}
// 获取历史 (String 类型)
const getHistoryList = (key: string): any[] => {
try {
const existing = localStorage.getItem(key)
const list = existing ? JSON.parse(existing) : []
return list.map((v: string) => ({ value: v }))
} catch (e) { return [] }
}
// 保存物料历史 (Object 类型)
const saveMaterialHistory = (item: any) => {
if (!item || !item.id) return
const key = HISTORY_KEYS.MATERIAL
try {
const existing = localStorage.getItem(key)
let list = existing ? JSON.parse(existing) : []
list = list.filter((i: any) => i.id !== item.id)
list.unshift({ ...item, isHistory: true })
if (list.length > 10) list = list.slice(0, 10)
localStorage.setItem(key, JSON.stringify(list))
} catch (e) {}
}
const getMaterialHistory = () => {
try {
const existing = localStorage.getItem(HISTORY_KEYS.MATERIAL)
return existing ? JSON.parse(existing) : []
} catch (e) { return [] }
}
// ------------------------------------
// Autocomplete 建议逻辑 (混合模式)
// ------------------------------------
const createFilter = (queryString: string) => {
return (item: any) => {
return (item.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0)
}
}
const getTableDataUnique = (field: string) => {
const uniqueItems = Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean)))
return uniqueItems.map(i => ({ value: i }))
}
const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => {
const tableList = getTableDataUnique(tableField)
const historyList = getHistoryList(storageKey)
const map = new Map()
historyList.forEach(i => map.set(i.value, i))
tableList.forEach(i => map.set(i.value, i))
const allList = Array.from(map.values())
const results = queryString ? allList.filter(createFilter(queryString)) : allList
cb(results)
}
// 1. 负责人
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb) const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value) const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
const fetchData = async () => { loading.value = true; try { const res: any = await getProductList(queryParams); tableData.value = res.data.items || []; total.value = res.data.total || 0 } finally { loading.value = false } }
const fetchData = async () => { const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
loading.value = true const handleSearchMaterial = async (query: string) => { searchLoading.value = true; try { const res: any = await searchMaterialBase(query); const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false })); if (!query) { const history = getMaterialHistory(); const historyIds = new Set(history.map((h: any) => h.id)); const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id)); materialOptions.value = [...history, ...filteredApi] } else { materialOptions.value = apiResults } } finally { searchLoading.value = false } }
try { const onMaterialSelected = (val: number) => { const item = materialOptions.value.find(i => i.id === val); if (item) { saveMaterialHistory(item); form.material_name = item.name; form.spec_model = item.spec; form.material_type = item.type; form.category = item.category } }
const res: any = await getProductList(queryParams)
tableData.value = res.data.items || []
total.value = res.data.total || 0
} finally { loading.value = false }
}
// ------------------------------------
// 物料搜索逻辑 (优化)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible) {
if (materialOptions.value.length === 0) {
handleSearchMaterial('')
}
}
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query)
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
if (!query) {
const history = getMaterialHistory()
const historyIds = new Set(history.map((h: any) => h.id))
const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))
materialOptions.value = [...history, ...filteredApi]
} else {
materialOptions.value = apiResults
}
} finally { searchLoading.value = false }
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
saveMaterialHistory(item)
form.material_name = item.name
form.spec_model = item.spec
form.material_type = item.type
form.category = item.category
}
}
const handleCreate = () => { const handleCreate = () => {
dialogStatus.value = 'create' dialogStatus.value = 'create'
@ -439,124 +481,146 @@ const handleCreate = () => {
materialOptions.value = [] materialOptions.value = []
} }
// ------------------------------------
// 核心更新逻辑 (回显三个图片字段)
// ------------------------------------
const handleUpdate = (row: any) => { const handleUpdate = (row: any) => {
dialogStatus.value = 'update' dialogStatus.value = 'update'
Object.assign(form, row) Object.assign(form, {
// 转换时间格式 ...row,
if(row.production_start_time && row.production_end_time) { product_photo: row.product_photo || [],
form.production_time_range = [row.production_start_time, row.production_end_time] quality_report_link: row.quality_report_link || [],
} else { inspection_report_link: row.inspection_report_link || [],
form.production_time_range = [] in_quantity: Number(row.qty_inbound),
} raw_material_cost: Number(row.raw_material_cost),
// 编辑模式下填充当前物料 manual_cost: Number(row.manual_cost),
materialOptions.value = [{ sale_price: Number(row.sale_price)
id: row.base_id, })
name: row.material_name, if(row.production_start_time && row.production_end_time) { form.production_time_range = [row.production_start_time, row.production_end_time] } else { form.production_time_range = [] }
spec: row.spec_model,
category: row.category, // 1. 成品实拍
isHistory: false productPhotoList.value = form.product_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
}]
// 2. 质量报告
const qReports = form.quality_report_link || []
qualityFileList.value = qReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qLinks = qReports.filter(r => isExternalLink(r))
quality_url.value = qLinks.length > 0 ? qLinks[0] : ''
// 3. 检测报告
const iReports = form.inspection_report_link || []
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }]
visible.value = true visible.value = true
} }
const getImageUrl = (url: string) => { if (!url) return ''; if (url.startsWith('http')) return url; return url }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { if (!list) return []; return list.filter(item => !isExternalLink(item)) }
const hasExternalLink = (list: string[]) => { if (!list) return false; return list.some(item => isExternalLink(item)) }
const beforeAvatarUpload = (rawFile: any) => { if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') { ElMessage.error('仅支持 JPG/PNG'); return false } if (rawFile.size / 1024 / 1024 > 5) { ElMessage.error('图片不能超过 5MB'); return false } return true }
const customUpload = async (options: any, targetField: 'product_photo' | 'quality_report_link' | 'inspection_report_link') => {
const { file, onSuccess, onError } = options
const formData = new FormData(); formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
form[targetField].push(newUrl)
ElMessage.success('上传成功')
onSuccess(res)
} else { ElMessage.error(res.msg || '上传失败'); onError(new Error(res.msg)) }
} catch (e) { ElMessage.error('网络错误'); onError(e) }
}
const handleRemoveImage = async (uploadFile: any, targetField: 'product_photo' | 'quality_report_link' | 'inspection_report_link') => {
try {
const urlToRemove = form[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form[targetField] = form[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) { const filename = urlToRemove.split('/').pop(); if (filename) await deleteFile(filename) }
ElMessage.success('已删除')
} catch (e) { console.error(e) }
}
const triggerCamera = (field: any) => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
const handleCameraFile = async (event: Event) => {
const input = event.target as HTMLInputElement; if (input.files && input.files[0]) {
const file = input.files[0]; if (!beforeAvatarUpload(file)) { input.value = ''; return }
const formData = new FormData(); formData.append('file', file); const loadingMsg = ElMessage.loading({ message: '上传中...', duration: 0 })
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; const field = currentCameraField.value; form[field].push(newUrl)
if (field === 'product_photo') productPhotoList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
else if (field === 'quality_report_link') qualityFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
else inspectionFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
ElMessage.success('拍照上传成功')
} else { ElMessage.error(res.msg || '上传失败') }
} catch (e) { ElMessage.error('网络错误') } finally { loadingMsg.close(); input.value = '' }
}
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
// ------------------------------------ // ------------------------------------
// 提交逻辑 (含自动打印) // 提交逻辑 (合并链接)
// ------------------------------------ // ------------------------------------
const submitForm = async () => { const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
if(valid) { if(valid) {
submitting.value = true submitting.value = true
try {
const payload = { ...form,
production_start_time: form.production_time_range?.[0],
production_end_time: form.production_time_range?.[1]
}
// 合并 Quality 链接
const qList = [...form.quality_report_link]
const qImages = qList.filter(item => !isExternalLink(item))
if (quality_url.value && !qList.includes(quality_url.value)) qImages.push(quality_url.value)
else if (quality_url.value) qImages.push(quality_url.value) // 重新添加输入框内容
// 合并 Inspection 链接
const iList = [...form.inspection_report_link]
const iImages = iList.filter(item => !isExternalLink(item))
if (inspection_url.value && !iList.includes(inspection_url.value)) iImages.push(inspection_url.value)
else if (inspection_url.value) iImages.push(inspection_url.value)
const payload = { ...form,
quality_report_link: qImages,
inspection_report_link: iImages,
production_start_time: form.production_time_range?.[0],
production_end_time: form.production_time_range?.[1]
}
delete payload.production_time_range
try {
if(dialogStatus.value === 'create') { if(dialogStatus.value === 'create') {
// 1. 创建入库
const res: any = await createProductInbound(payload) const res: any = await createProductInbound(payload)
ElMessage.success('入库成功') ElMessage.success('入库成功')
// 2. 自动打印
const newItem = res.data const newItem = res.data
if (newItem) { if (newItem) { ElMessage.info('发送打印...'); try { await executePrint(newItem); ElMessage.success('指令已发送') } catch (e: any) { ElMessage.warning('打印失败') } }
ElMessage.info('正在发送打印指令...') } else {
try {
await executePrint(newItem)
ElMessage.success('打印指令已发送')
} catch (printErr: any) {
console.error(printErr)
ElMessage.warning('入库成功,但自动打印失败:' + (printErr.msg || '未知错误'))
}
}
}
else {
await updateProductInbound(form.id!, payload) await updateProductInbound(form.id!, payload)
ElMessage.success('更新成功') 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 } catch(e:any) { ElMessage.error(e.msg || '失败') } finally { submitting.value = false }
fetchData()
} catch(e:any) { ElMessage.error(e.msg || '失败') }
finally { submitting.value = false }
} }
}) })
} }
const handleDelete = async (row: any) => { const handleDelete = async (row: any) => { try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch(e) { ElMessage.error('删除失败') } }
try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() }
catch(e) { ElMessage.error('删除失败') }
}
// ------------------------------------
// 打印逻辑 (手动 & 预览)
// ------------------------------------
const handlePrint = async (row: any) => { const handlePrint = async (row: any) => {
printVisible.value = true printVisible.value = true; printLoading.value = true; previewUrl.value = ''
printLoading.value = true currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku }
previewUrl.value = '' try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
// 构造产品特有的打印数据
const printData = {
global_print_id: row.global_print_id, // 需后端模型支持
material_name: row.material_name,
spec_model: row.spec_model,
category: row.category,
material_type: row.material_type,
warehouse_loc: row.warehouse_loc,
serial_number: row.serial_number,
sku: row.sku
}
currentPrintData.value = printData
try {
const res: any = await getLabelPreview(printData)
previewUrl.value = res.data
} catch (e) {
ElMessage.error('预览生成失败')
} finally {
printLoading.value = false
}
}
const confirmPrint = async () => {
printing.value = true
try {
await executePrint(currentPrintData.value)
ElMessage.success('指令已发送')
printVisible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '打印失败')
} finally {
printing.value = false
}
} }
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('已发送'); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => { const resetForm = () => {
materialOptions.value = [] materialOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
Object.assign(form, { Object.assign(form, {
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '',
sku: '', barcode: '', serial_number: '', in_date: '', sku: '', barcode: '', serial_number: '', in_date: '',
@ -565,7 +629,7 @@ const resetForm = () => {
bom_code: '', bom_version: '', work_order_code: '', order_id: '', bom_code: '', bom_version: '', work_order_code: '', order_id: '',
production_manager: '', production_time_range: [], production_manager: '', production_time_range: [],
raw_material_cost: 0, manual_cost: 0, sale_price: 0, raw_material_cost: 0, manual_cost: 0, sale_price: 0,
quality_report_link: '', inspection_report_link: '', detail_link: '' quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: ''
}) })
} }
@ -577,34 +641,39 @@ onMounted(() => fetchData())
<style scoped> <style scoped>
.product-module { background: #f5f7fa; padding: 20px; min-height: 100vh; } .product-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; margin-bottom: 20px; background: #fff; padding: 15px; border-radius: 8px; } .header-tools { display: flex; justify-content: space-between; margin-bottom: 20px; background: #fff; padding: 15px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.modern-table { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); } .modern-table { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; } .tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
.stock-num { font-weight: bold; font-size: 15px; } .stock-num { font-weight: bold; font-size: 15px; }
.sum-tag { margin-left: 4px; transform: scale(0.9); }
.form-card { background: #fff; border-radius: 8px; margin-bottom: 20px; border: 1px solid #e4e7ed; overflow: hidden; } .form-card { background: #fff; border-radius: 8px; margin-bottom: 20px; border: 1px solid #e4e7ed; overflow: hidden; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; display: flex; align-items: center; gap: 8px; } .card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.card-title .icon { font-size: 18px; }
.card-content { padding: 20px; } .card-content { padding: 20px; }
.basic-card { border-left: 4px solid #409EFF; } .basic-card { border-left: 4px solid #409EFF; } .basic-card .icon { color: #409EFF; }
.inbound-card { border-left: 4px solid #67C23A; } .inbound-card { border-left: 4px solid #67C23A; } .inbound-card .icon { color: #67C23A; }
.production-card { border-left: 4px solid #E6A23C; } .production-card { border-left: 4px solid #E6A23C; } .production-card .icon { color: #E6A23C; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; padding: 15px; margin: 10px 0; border-radius: 6px; } .identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; padding: 15px; margin: 10px 0; border-radius: 6px; }
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; padding: 0 5px; font-weight: bold; } .prefix-tag.sn { color: #409EFF; background: #ecf5ff; padding: 0 5px; font-weight: bold; border-radius: 4px; }
.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: 12px; margin-right: 10px; } .opt-spec { color: #8492a6; font-size: 12px; margin-right: 10px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background: #f5f7fa; border-bottom: 1px solid #dcdfe6; } .is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background: #f5f7fa; border-bottom: 1px solid #dcdfe6; padding-left: 0; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; } .search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 20px; border-top: 1px solid #ebeef5; }
.money-text { font-family: 'Consolas', monospace; color: #303133; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
.empty-preview { color: #909399; }
.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; }
/* 打印预览样式 */ .upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
.preview-box { :deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
min-height: 150px; :deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
display: flex; .camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
justify-content: center; .camera-card:hover { border-color: #409EFF; color: #409EFF; }
align-items: center; .camera-card .text { font-size: 12px; margin-top: 5px; }
background: #f5f7fa; .camera-card .el-icon { font-size: 24px; }
border-radius: 4px;
}
.empty-preview {
color: #909399;
}
</style> </style>