采购件图像上传初实现

This commit is contained in:
dxc
2026-02-03 11:16:12 +08:00
parent efcd2d923c
commit 7fa40115d9
7 changed files with 510 additions and 91 deletions

View File

@ -19,14 +19,15 @@ services:
# --- 后端 Flask 服务 --- # --- 后端 Flask 服务 ---
backend: backend:
build: build:
context: ./inventory-backend # 【修改】指向你的新后端目录 context: ./inventory-backend # 指向你的新后端目录
container_name: inventory_api container_name: inventory_api
restart: always restart: always
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./inventory-backend:/app # 挂载代码,实现热更新 - ./inventory-backend:/app # 挂载代码,实现热更新
# 加上 --reload 参数,代码变了自动重启 # 【核心修改】显式挂载 uploads 目录,确保图片持久化且宿主机可见
- ./inventory-backend/uploads:/app/uploads
command: gunicorn -c gunicorn.conf.py run:app --reload command: gunicorn -c gunicorn.conf.py run:app --reload
environment: environment:
# Host 必须写 'db' # Host 必须写 'db'
@ -34,18 +35,17 @@ services:
depends_on: depends_on:
- db - db
# --- 前端 Vue+Nginx 服务 --- # --- 前端 Vue 开发服务 ---
# --- 前端 Vue 开发服务 ---
frontend: frontend:
build: build:
context: ./inventory-web context: ./inventory-web
container_name: inventory_ui container_name: inventory_ui
restart: always restart: always
# 【重点1】把本地代码挂载进去,实现“热更新” # 把本地代码挂载进去,实现“热更新”
volumes: volumes:
- ./inventory-web:/app - ./inventory-web:/app
- /app/node_modules # 排除 node_modules防止冲突 - /app/node_modules # 排除 node_modules防止冲突
# 【重点2】开发模式端口通常是 5173 # 开发模式端口通常是 5173
ports: ports:
- "5173:5173" - "5173:5173"
depends_on: depends_on:

View File

@ -3,7 +3,7 @@
from flask import Flask from flask import Flask
from config import Config from config import Config
from app.extensions import db, migrate, cors from app.extensions import db, migrate, cors
import os
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
@ -14,7 +14,8 @@ def create_app():
migrate.init_app(app, db) migrate.init_app(app, db)
# 确保跨域配置 # 确保跨域配置
cors.init_app(app, resources={r"/api/*": {"origins": "*"}}) # 允许 /api/ 开头的请求跨域
cors.init_app(app, resources={r"/*": {"origins": "*"}}) # 放宽跨域限制,防止图片访问被拦截
# ========================================================= # =========================================================
# 2. 注册蓝图 (Blueprints) # 2. 注册蓝图 (Blueprints)
@ -25,12 +26,9 @@ def create_app():
# ----------------------------------------------------- # -----------------------------------------------------
try: try:
# 指向聚合文件: app/api/v1/inbound/__init__.py # 指向聚合文件: app/api/v1/inbound/__init__.py
# 该文件里应该包含了 buy, semi, base, product 的聚合逻辑
from app.api.v1.inbound import inbound_bp from app.api.v1.inbound import inbound_bp
# 注册父蓝图,路由前缀为 /api/v1/inbound # 注册父蓝图,路由前缀为 /api/v1/inbound
# 最终路由效果:
# /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound') app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功") print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功")
@ -39,14 +37,12 @@ def create_app():
print(f"❌ 错误: Inbound 模块导入失败: {e}") print(f"❌ 错误: Inbound 模块导入失败: {e}")
# ----------------------------------------------------- # -----------------------------------------------------
# 2.2 注册通用打印模块 (Common Print) - [新增] # 2.2 注册通用打印模块 (Common Print)
# ----------------------------------------------------- # -----------------------------------------------------
try: try:
from app.api.v1.common.print import print_bp from app.api.v1.common.print import print_bp
# 注册打印蓝图 # 注册打印蓝图
# 前端请求地址: /common/print/preview
# 配合 baseURL=/api/v1最终对应后端: /api/v1/common/print/preview
app.register_blueprint(print_bp, url_prefix='/api/v1/common/print') app.register_blueprint(print_bp, url_prefix='/api/v1/common/print')
print("✅ Print (Label Printing) 模块注册成功") print("✅ Print (Label Printing) 模块注册成功")
@ -54,6 +50,25 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: Print 模块导入失败: {e}") print(f"❌ 错误: Print 模块导入失败: {e}")
# -----------------------------------------------------
# 2.3 [新增] 注册通用上传模块 (Common Upload)
# -----------------------------------------------------
try:
from app.api.v1.common.upload import upload_bp
# 【核心修改】注册方式 1: 标准路径 (对应 /api/v1/common/files/xxx)
app.register_blueprint(upload_bp, url_prefix='/api/v1/common')
# 【核心修改】注册方式 2: 兼容路径 (对应 /v1/common/files/xxx)
# 解决部分代理服务器剥离 /api 前缀导致的 404 问题
# name='upload_fallback' 防止蓝图名称冲突
app.register_blueprint(upload_bp, url_prefix='/v1/common', name='upload_fallback')
print("✅ Upload (File Storage) 模块注册成功 (双路径兼容模式)")
except ImportError as e:
print(f"❌ 错误: Upload 模块导入失败: {e}")
# ========================================================= # =========================================================
# 3. 预加载数据模型 (解决 relationship 找不到模型的问题) # 3. 预加载数据模型 (解决 relationship 找不到模型的问题)
# ========================================================= # =========================================================

View File

@ -0,0 +1,132 @@
# 文件路径: inventory-backend/app/api/v1/common/upload.py
import os
import uuid
from flask import Blueprint, request, jsonify, send_from_directory
# 定义蓝图
upload_bp = Blueprint('upload', __name__)
# =========================================================
# 配置上传路径 (核心修改:确保路径绝对准确)
# =========================================================
# 向上寻找直到找到 inventory-backend 目录,或者默认为当前文件的上级目录的...上级
# 这种方式比数 dirname 层级更稳健
def get_project_root():
"""获取项目根目录 inventory-backend"""
current_path = os.path.abspath(__file__)
# 循环向上查找,直到找到名为 inventory-backend 的目录
# 如果你的根目录名字不是 inventory-backend请修改这里的判断逻辑
# 或者直接使用相对路径回退 5 层: api/v1/common -> app -> inventory-backend
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_path)))))
return base
BASE_DIR = get_project_root()
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
# 允许上传的文件后缀
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_folder_exists():
if not os.path.exists(UPLOAD_FOLDER):
try:
os.makedirs(UPLOAD_FOLDER)
print(f"✅ [Upload] 目录创建成功: {UPLOAD_FOLDER}")
except Exception as e:
print(f"❌ [Upload] 目录创建失败: {e}")
# ------------------------------------------------------------------
# 1. 文件上传接口
# URL: /api/v1/common/upload (POST)
# ------------------------------------------------------------------
@upload_bp.route('/upload', methods=['POST'])
def upload_file():
ensure_upload_folder_exists()
if 'file' not in request.files:
return jsonify({"code": 400, "msg": "未找到文件部分"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"code": 400, "msg": "未选择文件"}), 400
if file and allowed_file(file.filename):
try:
ext = file.filename.rsplit('.', 1)[1].lower()
new_filename = f"{uuid.uuid4().hex}.{ext}"
save_path = os.path.join(UPLOAD_FOLDER, new_filename)
file.save(save_path)
print(f"💾 [Upload] 文件已保存: {save_path}")
# 生成访问 URL
# 这里的路径必须与 __init__.py 中注册的 url_prefix + 路由匹配
file_url = f"/api/v1/common/files/{new_filename}"
return jsonify({
"code": 200,
"msg": "上传成功",
"data": {
"url": file_url,
"filename": new_filename
}
})
except Exception as e:
print(f"❌ [Upload] 保存异常: {e}")
return jsonify({"code": 500, "msg": "文件保存失败"}), 500
return jsonify({"code": 400, "msg": "不支持的文件格式"}), 400
# ------------------------------------------------------------------
# 2. 静态文件访问接口 (回显)
# URL: /api/v1/common/files/<filename>
# ------------------------------------------------------------------
@upload_bp.route('/files/<filename>')
def uploaded_file(filename):
# 打印日志帮助调试 404 问题
full_path = os.path.join(UPLOAD_FOLDER, filename)
if not os.path.exists(full_path):
print(f"❌ [File Access] 文件未找到: {full_path}")
return jsonify({"code": 404, "msg": "文件不存在"}), 404
return send_from_directory(UPLOAD_FOLDER, filename)
# ------------------------------------------------------------------
# 3. 文件删除接口 (同步删除物理文件)
# URL: /api/v1/common/files/<filename> (DELETE)
# ------------------------------------------------------------------
@upload_bp.route('/files/<filename>', methods=['DELETE'])
def delete_file(filename):
try:
# 安全处理文件名
safe_filename = os.path.basename(filename)
file_path = os.path.join(UPLOAD_FOLDER, safe_filename)
print(f"🗑️ [Delete] 尝试删除文件: {file_path}")
if os.path.exists(file_path):
os.remove(file_path)
print(f"✅ [Delete] 文件删除成功")
return jsonify({"code": 200, "msg": "文件已删除"})
else:
print(f"⚠️ [Delete] 文件不存在,无需删除")
# 即使文件不存在也返回成功,保证前端流程继续
return jsonify({"code": 200, "msg": "文件不存在或已删除"})
except Exception as e:
print(f"❌ [Delete] 删除异常: {e}")
return jsonify({"code": 500, "msg": f"删除失败: {str(e)}"}), 500

View File

@ -36,13 +36,16 @@ class StockBuy(db.Model):
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0) exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
supplier_name = db.Column(db.String(255)) supplier_name = db.Column(db.String(255))
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name buyer_name = db.Column(db.String(100))
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email buyer_email = db.Column(db.String(100))
original_link = db.Column(db.Text) # 对应 SQL: original_link original_link = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
arrival_photo = db.Column(db.Text) arrival_photo = db.Column(db.Text)
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq) # [新增] 检测报告图片路径
inspection_report = db.Column(db.Text)
# 全局打印流水号
global_print_id = db.Column(db.Integer) global_print_id = db.Column(db.Integer)
# 关系定义 # 关系定义
@ -86,7 +89,9 @@ class StockBuy(db.Model):
'detail_link': self.detail_link, 'detail_link': self.detail_link,
'arrival_photo': self.arrival_photo, 'arrival_photo': self.arrival_photo,
# [新增] 返回全局打印ID及其格式化字符串 # [新增] 返回检测报告字段
'inspection_report': self.inspection_report,
'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

@ -82,9 +82,8 @@ class BuyInboundService:
generated_sku = str(next_global_id).zfill(10) generated_sku = str(next_global_id).zfill(10)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 3. 条码逻辑处理 (核心修改) # 3. 条码逻辑处理
# 如果前端没传条码(barcode),则默认使用系统生成的 SKU 作为条码 # 如果前端没传条码(barcode),则默认使用系统生成的 SKU 作为条码
# 这样保证了条码生成依据是 "自动填写的 SKU"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
final_barcode = data.get('barcode') final_barcode = data.get('barcode')
if not final_barcode: if not final_barcode:
@ -93,8 +92,8 @@ class BuyInboundService:
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 sku=generated_sku,
barcode=final_barcode, # 如果未输入则存入SKU值 barcode=final_barcode,
in_date=in_date_val, in_date=in_date_val,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
@ -114,13 +113,15 @@ class BuyInboundService:
buyer_email=data.get('purchaser_email'), buyer_email=data.get('purchaser_email'),
original_link=data.get('source_link'), original_link=data.get('source_link'),
detail_link=data.get('detail_link'), detail_link=data.get('detail_link'),
arrival_photo=data.get('arrival_photo') arrival_photo=data.get('arrival_photo'),
# [新增] 保存检测报告字段
inspection_report=data.get('inspection_report')
) )
db.session.add(new_stock) db.session.add(new_stock)
db.session.commit() db.session.commit()
# 返回创建的对象实例
return new_stock return new_stock
except Exception as e: except Exception as e:
@ -151,7 +152,10 @@ class BuyInboundService:
'exchange_rate': 'exchange_rate', 'exchange_rate': 'exchange_rate',
'purchaser': 'buyer_name', 'purchaser': 'buyer_name',
'purchaser_email': 'buyer_email', 'purchaser_email': 'buyer_email',
'source_link': 'original_link' 'source_link': 'original_link',
# [新增] 允许更新检测报告
'inspection_report': 'inspection_report'
} }
for frontend_key, db_attr in field_mapping.items(): for frontend_key, db_attr in field_mapping.items():
@ -207,7 +211,6 @@ class BuyInboundService:
@staticmethod @staticmethod
def get_list(page, limit, keyword=None): def get_list(page, limit, keyword=None):
try: try:
# 1. 查询分页数据
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id) query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
if keyword: if keyword:
@ -223,9 +226,6 @@ class BuyInboundService:
pagination = query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit, error_out=False) pagination = query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit, error_out=False)
# ---------------------------------------------------------------------
# 计算总库存 (聚合)
# ---------------------------------------------------------------------
current_items = pagination.items current_items = pagination.items
base_ids = list(set([item.base_id for item in current_items if item.base_id])) base_ids = list(set([item.base_id for item in current_items if item.base_id]))
@ -289,6 +289,9 @@ class BuyInboundService:
'detail_link': item.detail_link, 'detail_link': item.detail_link,
'arrival_photo': item.arrival_photo, 'arrival_photo': item.arrival_photo,
# [新增] 返回检测报告
'inspection_report': 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 "" 'global_print_id_str': f"{item.global_print_id:08d}" if item.global_print_id else ""
} }

View File

@ -42,4 +42,22 @@ export function searchMaterialBase(keyword: string) {
method: 'get', method: 'get',
params: { keyword } params: { keyword }
}) })
}
// 6. 文件上传 (用于图片/拍照)
export function uploadFile(data: FormData) {
return request({
url: '/common/upload', // 对应后端 /api/v1/common/upload
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 7. [新增] 文件删除
export function deleteFile(filename: string) {
return request({
url: `/common/files/${filename}`, // 对应后端 /api/v1/common/files/<filename>
method: 'delete'
})
} }

View File

@ -83,6 +83,25 @@
</el-tag> </el-tag>
</template> </template>
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)">
<div v-if="scope.row[col.prop]" 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(scope.row[col.prop])"
:preview-src-list="[getImageUrl(scope.row[col.prop])]"
preview-teleported
fit="cover"
>
<template #error>
<div style="display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; background: #f5f7fa; color: #909399;">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<span v-else class="text-placeholder">-</span>
</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">
@ -338,9 +357,59 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="到货图片" prop="arrival_photo"> <el-form-item label="到货图片" prop="arrival_photo">
<el-input v-model="form.arrival_photo" placeholder="输入图片 URL"/> <div style="display: flex; align-items: flex-start; gap: 10px;">
<div v-if="form.arrival_photo" class="preview-wrapper">
<img :src="getImageUrl(form.arrival_photo)" class="avatar" />
<div class="delete-overlay">
<el-button type="danger" :icon="Delete" circle @click="handleRemoveImage('arrival_photo')" />
</div>
</div>
<el-upload
v-else
class="avatar-uploader"
action="#"
:http-request="(opts) => customUpload(opts, 'arrival_photo')"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
>
<el-icon class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</div>
<el-input v-model="form.arrival_photo" placeholder="上传后生成" style="margin-top: 5px;" size="small" readonly/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report">
<div style="display: flex; align-items: flex-start; gap: 10px;">
<div v-if="form.inspection_report" class="preview-wrapper">
<img :src="getImageUrl(form.inspection_report)" class="avatar" />
<div class="delete-overlay">
<el-button type="danger" :icon="Delete" circle @click="handleRemoveImage('inspection_report')" />
</div>
</div>
<el-upload
v-else
class="avatar-uploader"
action="#"
:http-request="(opts) => customUpload(opts, 'inspection_report')"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
>
<el-icon class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</div>
<el-input v-model="form.inspection_report" placeholder="上传后生成" style="margin-top: 5px;" size="small" readonly/>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -490,15 +559,17 @@
<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, Lock, Box, House, InfoFilled, Link, Printer} from '@element-plus/icons-vue' import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
import {ElMessage} from 'element-plus' import {ElMessage, ElMessageBox} from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
getBuyList, getBuyList,
createBuyInbound, createBuyInbound,
updateBuyInbound, updateBuyInbound,
deleteBuyInbound, deleteBuyInbound,
searchMaterialBase searchMaterialBase,
uploadFile,
deleteFile
} from '@/api/inbound/buy' } from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print' import {getLabelPreview, executePrint} from '@/api/common/print'
@ -526,6 +597,9 @@ const currentPrintData = ref<any>({})
const entryMode = ref('batch') const entryMode = ref('batch')
const modeLocked = ref(false) const modeLocked = ref(false)
// 拍照/上传相关
const cameraInputRef = ref<HTMLInputElement | null>(null)
// 列定义 // 列定义
const baseColumns = [ const baseColumns = [
{prop: 'material_name', label: '名称'}, {prop: 'material_name', label: '名称'},
@ -558,17 +632,18 @@ const stockColumns = [
{prop: 'purchaser_email', label: '邮箱', minWidth: '150'}, {prop: 'purchaser_email', label: '邮箱', minWidth: '150'},
{prop: 'source_link', label: '采购链接', minWidth: '100'}, {prop: 'source_link', label: '采购链接', minWidth: '100'},
{prop: 'detail_link', label: '详情链接', minWidth: '100'}, {prop: 'detail_link', label: '详情链接', minWidth: '100'},
{prop: 'arrival_photo', label: '到货图', minWidth: '100'} {prop: 'arrival_photo', label: '到货图', minWidth: '100'},
{prop: 'inspection_report', label: '检测报告', minWidth: '100'} // 新增列
] ]
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
// 表头持久化
const STORAGE_KEY_COLS = 'stock_buy_visible_columns' const STORAGE_KEY_COLS = 'stock_buy_visible_columns'
// 确保 arrival_photo 和 inspection_report 在默认列中
const defaultColumns = [ const defaultColumns = [
'material_name', 'category', 'material_type', 'spec_model', 'unit', 'material_name', 'category', 'material_type', 'spec_model', 'unit',
'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status', 'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status',
'unit_price', 'total_price', 'supplier_name', 'purchaser', 'qty_stock', 'qty_available' 'unit_price', 'total_price', 'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report'
] ]
const getSavedColumns = () => { const getSavedColumns = () => {
@ -598,7 +673,9 @@ const form = reactive({
warehouse_location: '', warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', supplier_name: '', purchaser: '', purchaser_email: '',
source_link: '', detail_link: '', arrival_photo: '' source_link: '', detail_link: '',
arrival_photo: '',
inspection_report: '' // 新增字段
}) })
// ------------------------------------ // ------------------------------------
@ -611,23 +688,20 @@ const HISTORY_KEYS = {
MATERIAL: 'history_materials' MATERIAL: 'history_materials'
} }
// 保存历史 (String 类型)
const saveToHistory = (key: string, value: string) => { const saveToHistory = (key: string, value: string) => {
if (!value) return if (!value) return
try { try {
const existing = localStorage.getItem(key) const existing = localStorage.getItem(key)
let list = existing ? JSON.parse(existing) : [] let list = existing ? JSON.parse(existing) : []
// 移除旧的,添加到前面
list = list.filter((i: string) => i !== value) list = list.filter((i: string) => i !== value)
list.unshift(value) list.unshift(value)
if (list.length > 20) list = list.slice(0, 20) // 最多存20条 if (list.length > 20) list = list.slice(0, 20)
localStorage.setItem(key, JSON.stringify(list)) localStorage.setItem(key, JSON.stringify(list))
} catch (e) { } catch (e) {
console.error('save history failed', e) console.error('save history failed', e)
} }
} }
// 获取历史 (String 类型)
const getHistoryList = (key: string): any[] => { const getHistoryList = (key: string): any[] => {
try { try {
const existing = localStorage.getItem(key) const existing = localStorage.getItem(key)
@ -638,7 +712,6 @@ const getHistoryList = (key: string): any[] => {
} }
} }
// 保存物料历史 (Object 类型)
const saveMaterialHistory = (item: any) => { const saveMaterialHistory = (item: any) => {
if (!item || !item.id) return if (!item || !item.id) return
const key = HISTORY_KEYS.MATERIAL const key = HISTORY_KEYS.MATERIAL
@ -664,7 +737,7 @@ const getMaterialHistory = () => {
// ------------------------------------ // ------------------------------------
// Autocomplete 建议逻辑 (混合模式:历史+当前表格) // Autocomplete & Search Logic
// ------------------------------------ // ------------------------------------
const createFilter = (queryString: string) => { const createFilter = (queryString: string) => {
return (item: any) => { return (item: any) => {
@ -672,40 +745,31 @@ const createFilter = (queryString: string) => {
} }
} }
// 辅助函数:从当前表格提取
const getTableDataUnique = (field: string) => { const getTableDataUnique = (field: string) => {
const uniqueItems = Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))) const uniqueItems = Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean)))
return uniqueItems.map(i => ({value: i})) return uniqueItems.map(i => ({value: i}))
} }
// 通用查询: 历史记录 + 当前页面数据
const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => { const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => {
const tableList = getTableDataUnique(tableField) const tableList = getTableDataUnique(tableField)
const historyList = getHistoryList(storageKey) const historyList = getHistoryList(storageKey)
// 合并去重
const map = new Map() const map = new Map()
historyList.forEach(i => map.set(i.value, i)) historyList.forEach(i => map.set(i.value, i))
tableList.forEach(i => map.set(i.value, i)) tableList.forEach(i => map.set(i.value, i))
const allList = Array.from(map.values()) const allList = Array.from(map.values())
const results = queryString ? allList.filter(createFilter(queryString)) : allList const results = queryString ? allList.filter(createFilter(queryString)) : allList
cb(results) cb(results)
} }
// 1. 供应商
const querySearchSupplier = (qs: string, cb: any) => mixedSearch(qs, 'supplier_name', HISTORY_KEYS.SUPPLIER, cb) const querySearchSupplier = (qs: string, cb: any) => mixedSearch(qs, 'supplier_name', HISTORY_KEYS.SUPPLIER, cb)
const handleSupplierSelect = (item: any) => saveToHistory(HISTORY_KEYS.SUPPLIER, item.value) const handleSupplierSelect = (item: any) => saveToHistory(HISTORY_KEYS.SUPPLIER, item.value)
// 2. 采购人
const querySearchPurchaser = (qs: string, cb: any) => mixedSearch(qs, 'purchaser', HISTORY_KEYS.PURCHASER, cb) const querySearchPurchaser = (qs: string, cb: any) => mixedSearch(qs, 'purchaser', HISTORY_KEYS.PURCHASER, cb)
const handlePurchaserSelect = (item: any) => saveToHistory(HISTORY_KEYS.PURCHASER, item.value) const handlePurchaserSelect = (item: any) => saveToHistory(HISTORY_KEYS.PURCHASER, item.value)
// 3. 邮箱
const querySearchEmail = (qs: string, cb: any) => mixedSearch(qs, 'purchaser_email', HISTORY_KEYS.EMAIL, cb) const querySearchEmail = (qs: string, cb: any) => mixedSearch(qs, 'purchaser_email', HISTORY_KEYS.EMAIL, cb)
const handleEmailSelect = (item: any) => saveToHistory(HISTORY_KEYS.EMAIL, item.value) const handleEmailSelect = (item: any) => saveToHistory(HISTORY_KEYS.EMAIL, item.value)
// 4. 币种 (固定+过滤)
const currencyOptions = [ const currencyOptions = [
{value: 'CNY', desc: '人民币'}, {value: 'CNY', desc: '人民币'},
{value: 'USD', desc: '美元'}, {value: 'USD', desc: '美元'},
@ -717,9 +781,8 @@ const querySearchCurrency = (queryString: string, cb: any) => {
} }
// ------------------------------------ // ------------------------------------
// 物料搜索逻辑 (优化:支持空查询加载默认值) // Material Search Logic
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible) { if (visible) {
if (materialOptions.value.length === 0) { if (materialOptions.value.length === 0) {
@ -751,7 +814,6 @@ const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val) const item = materialOptions.value.find(i => i.id === val)
if (item) { if (item) {
saveMaterialHistory(item) saveMaterialHistory(item)
form.material_name = item.name form.material_name = item.name
form.spec_model = item.spec form.spec_model = item.spec
form.category = item.category form.category = item.category
@ -762,7 +824,7 @@ const onMaterialSelected = (val: number) => {
} }
// ------------------------------------ // ------------------------------------
// 逻辑校验规则 // Validation Logic
// ------------------------------------ // ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => { const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback() if (!value) return callback()
@ -797,7 +859,7 @@ const rules = {
} }
// ------------------------------------ // ------------------------------------
// 核心逻辑函数 // Business Logic: Batch/SN Mode
// ------------------------------------ // ------------------------------------
const checkHistoryAndSetMode = async (baseId: number) => { const checkHistoryAndSetMode = async (baseId: number) => {
try { try {
@ -884,17 +946,36 @@ const handleUpdate = (row: any) => {
resetForm() resetForm()
modeLocked.value = true modeLocked.value = true
form.id = row.id // 映射所有字段,包括新增的 inspection_report
form.base_id = row.base_id Object.assign(form, {
form.material_name = row.material_name id: row.id,
form.spec_model = row.spec_model base_id: row.base_id,
form.category = row.category material_name: row.material_name,
form.unit = row.unit spec_model: row.spec_model,
form.material_type = row.material_type category: row.category,
form.sku = row.sku unit: row.unit,
form.barcode = row.barcode material_type: row.material_type,
form.in_date = row.inbound_date sku: row.sku,
form.warehouse_location = row.warehouse_loc barcode: row.barcode,
in_date: row.inbound_date,
warehouse_location: row.warehouse_loc,
status: row.status,
inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound),
stock_quantity: Number(row.qty_stock),
available_quantity: Number(row.qty_available),
unit_price: Number(row.unit_price),
total_price: Number(row.total_price),
currency: row.currency,
exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name,
purchaser: row.purchaser,
purchaser_email: row.purchaser_email,
source_link: row.source_link,
detail_link: row.detail_link,
arrival_photo: row.arrival_photo,
inspection_report: row.inspection_report // 映射新字段
})
if (row.serial_number) { if (row.serial_number) {
entryMode.value = 'serial' entryMode.value = 'serial'
@ -906,23 +987,6 @@ const handleUpdate = (row: any) => {
form.serial_number = '' form.serial_number = ''
} }
form.status = row.status
form.inspection_status = row.inspection_status
form.in_quantity = Number(row.qty_inbound) || 0
form.stock_quantity = Number(row.qty_stock) || 0
form.available_quantity = Number(row.qty_available) || 0
form.unit_price = Number(row.unit_price) || 0
form.total_price = Number(row.total_price) || 0
form.currency = row.currency
form.exchange_rate = Number(row.exchange_rate)
form.supplier_name = row.supplier_name
form.purchaser = row.purchaser
form.purchaser_email = row.purchaser_email
form.source_link = row.source_link
form.detail_link = row.detail_link
form.arrival_photo = row.arrival_photo
materialOptions.value = [{ materialOptions.value = [{
id: row.base_id, id: row.base_id,
name: row.material_name, name: row.material_name,
@ -934,7 +998,122 @@ const handleUpdate = (row: any) => {
} }
// ------------------------------------ // ------------------------------------
// 提交逻辑 (新增自动打印逻辑) // 图片上传、拍照、删除逻辑 (通用化)
// ------------------------------------
// 1. 获取图片URL辅助函数
const getImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
// 如果是相对路径,直接返回 (假设后端代理已配置好)
return url
}
// 2. 校验
const beforeAvatarUpload = (rawFile: any) => {
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
ElMessage.error('图片必须是 JPG 或 PNG 格式!')
return false
} else if (rawFile.size / 1024 / 1024 > 5) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
// 3. 自定义上传 (支持不同字段)
const customUpload = async (options: any, targetField: keyof typeof form) => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
// @ts-ignore
form[targetField] = res.data.url
ElMessage.success('上传成功')
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败')
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误')
onError(e)
}
}
// 4. 拍照触发 (逻辑保留UI已移除)
const triggerCamera = () => {
if (cameraInputRef.value) {
cameraInputRef.value.click()
}
}
// 5. 处理拍照文件 (逻辑保留UI已移除)
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) {
form.arrival_photo = res.data.url // 默认拍给到货图,如果需要给检测报告需扩展逻辑
ElMessage.success('拍照上传成功')
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch (e) {
ElMessage.error('网络错误,上传失败')
} finally {
loadingMsg.close()
input.value = '' // 清空以便下次触发
}
}
}
// 6. 删除图片 (带物理删除,支持指定字段)
const handleRemoveImage = (targetField: keyof typeof form) => {
ElMessageBox.confirm(
'确定要删除当前图片吗?',
'提示',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
// @ts-ignore
const url = form[targetField]
if (url) {
// 解析文件名: /api/v1/common/files/xxxx.jpg -> xxxx.jpg
const filename = url.split('/').pop()
if (filename) {
// 调用后端删除文件
await deleteFile(filename)
}
}
// 清空前端引用
// @ts-ignore
form[targetField] = ''
ElMessage.success('图片已删除')
} catch (e) {
console.error(e)
ElMessage.error('删除失败')
}
}).catch(() => {})
}
// ------------------------------------
// 提交逻辑
// ------------------------------------ // ------------------------------------
const submitForm = async () => { const submitForm = async () => {
if (!formRef.value) return if (!formRef.value) return
@ -947,8 +1126,7 @@ const submitForm = async () => {
const res: any = await createBuyInbound(form) const res: any = await createBuyInbound(form)
ElMessage.success('入库成功') ElMessage.success('入库成功')
// 2. 自动打印 (使用返回的完整数据) // 2. 自动打印
// res.data 包含了 newly created stock item, with generated ID and SKU
const newItem = res.data const newItem = res.data
if (newItem) { if (newItem) {
ElMessage.info('正在发送打印指令...') ElMessage.info('正在发送打印指令...')
@ -1013,7 +1191,7 @@ const handlePrint = async (row: any) => {
warehouse_loc: row.warehouse_loc, warehouse_loc: row.warehouse_loc,
serial_number: row.serial_number, serial_number: row.serial_number,
batch_number: row.batch_number, batch_number: row.batch_number,
sku: row.sku // 【重要】显式增加 SKU 字段传递给后端 sku: row.sku
} }
currentPrintData.value = printData currentPrintData.value = printData
@ -1052,7 +1230,9 @@ const resetForm = () => {
warehouse_location: '', warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', supplier_name: '', purchaser: '', purchaser_email: '',
source_link: '', detail_link: '', arrival_photo: '' source_link: '', detail_link: '',
arrival_photo: '',
inspection_report: '' // 重置新增字段
}) })
} }
@ -1339,4 +1519,70 @@ onMounted(() => fetchData())
.empty-preview { .empty-preview {
color: #909399; color: #909399;
} }
/* 上传相关样式 */
.avatar-uploader .avatar {
width: 100px;
height: 100px;
display: block;
object-fit: cover;
border-radius: 6px;
}
/* 删除遮罩层 */
.preview-wrapper {
position: relative;
width: 100px;
height: 100px;
border-radius: 6px;
overflow: hidden;
}
.delete-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s;
cursor: pointer;
}
.preview-wrapper:hover .delete-overlay {
opacity: 1;
}
.avatar-uploader :deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 100px;
height: 100px;
}
.avatar-uploader :deep(.el-upload:hover) {
border-color: var(--el-color-primary);
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
}
/* 隐藏拍照按钮容器 */
.camera-trigger {
display: none; /* 根据需求隐藏,如需恢复改为 flex */
}
</style> </style>