采购件图像上传初实现

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

View File

@ -3,7 +3,7 @@
from flask import Flask
from config import Config
from app.extensions import db, migrate, cors
import os
def create_app():
app = Flask(__name__)
@ -14,7 +14,8 @@ def create_app():
migrate.init_app(app, db)
# 确保跨域配置
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
# 允许 /api/ 开头的请求跨域
cors.init_app(app, resources={r"/*": {"origins": "*"}}) # 放宽跨域限制,防止图片访问被拦截
# =========================================================
# 2. 注册蓝图 (Blueprints)
@ -25,12 +26,9 @@ def create_app():
# -----------------------------------------------------
try:
# 指向聚合文件: app/api/v1/inbound/__init__.py
# 该文件里应该包含了 buy, semi, base, product 的聚合逻辑
from app.api.v1.inbound import inbound_bp
# 注册父蓝图,路由前缀为 /api/v1/inbound
# 最终路由效果:
# /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功")
@ -39,14 +37,12 @@ def create_app():
print(f"❌ 错误: Inbound 模块导入失败: {e}")
# -----------------------------------------------------
# 2.2 注册通用打印模块 (Common Print) - [新增]
# 2.2 注册通用打印模块 (Common Print)
# -----------------------------------------------------
try:
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')
print("✅ Print (Label Printing) 模块注册成功")
@ -54,6 +50,25 @@ def create_app():
except ImportError as 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 找不到模型的问题)
# =========================================================

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)
supplier_name = db.Column(db.String(255))
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email
original_link = db.Column(db.Text) # 对应 SQL: original_link
buyer_name = db.Column(db.String(100))
buyer_email = db.Column(db.String(100))
original_link = db.Column(db.Text)
detail_link = 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)
# 关系定义
@ -86,7 +89,9 @@ class StockBuy(db.Model):
'detail_link': self.detail_link,
'arrival_photo': self.arrival_photo,
# [新增] 返回全局打印ID及其格式化字符串
# [新增] 返回检测报告字段
'inspection_report': self.inspection_report,
'global_print_id': self.global_print_id,
'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)
# ------------------------------------------------------------------
# 3. 条码逻辑处理 (核心修改)
# 3. 条码逻辑处理
# 如果前端没传条码(barcode),则默认使用系统生成的 SKU 作为条码
# 这样保证了条码生成依据是 "自动填写的 SKU"
# ------------------------------------------------------------------
final_barcode = data.get('barcode')
if not final_barcode:
@ -93,8 +92,8 @@ class BuyInboundService:
new_stock = StockBuy(
base_id=material.id,
global_print_id=next_global_id,
sku=generated_sku, # 自动生成的SKU
barcode=final_barcode, # 如果未输入则存入SKU值
sku=generated_sku,
barcode=final_barcode,
in_date=in_date_val,
serial_number=data.get('serial_number'),
@ -114,13 +113,15 @@ class BuyInboundService:
buyer_email=data.get('purchaser_email'),
original_link=data.get('source_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.commit()
# 返回创建的对象实例
return new_stock
except Exception as e:
@ -151,7 +152,10 @@ class BuyInboundService:
'exchange_rate': 'exchange_rate',
'purchaser': 'buyer_name',
'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():
@ -207,7 +211,6 @@ class BuyInboundService:
@staticmethod
def get_list(page, limit, keyword=None):
try:
# 1. 查询分页数据
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
if keyword:
@ -223,9 +226,6 @@ class BuyInboundService:
pagination = query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit, error_out=False)
# ---------------------------------------------------------------------
# 计算总库存 (聚合)
# ---------------------------------------------------------------------
current_items = pagination.items
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,
'arrival_photo': item.arrival_photo,
# [新增] 返回检测报告
'inspection_report': item.inspection_report,
'global_print_id': item.global_print_id,
'global_print_id_str': f"{item.global_print_id:08d}" if item.global_print_id else ""
}

View File

@ -43,3 +43,21 @@ export function searchMaterialBase(keyword: string) {
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>
</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')">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank"
:underline="false">
@ -338,9 +357,59 @@
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<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-col>
</el-row>
@ -490,15 +559,17 @@
<script setup lang="ts">
import {ref, reactive, onMounted, watch} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer} from '@element-plus/icons-vue'
import {ElMessage} from 'element-plus'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import dayjs from 'dayjs'
import {
getBuyList,
createBuyInbound,
updateBuyInbound,
deleteBuyInbound,
searchMaterialBase
searchMaterialBase,
uploadFile,
deleteFile
} from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print'
@ -526,6 +597,9 @@ const currentPrintData = ref<any>({})
const entryMode = ref('batch')
const modeLocked = ref(false)
// 拍照/上传相关
const cameraInputRef = ref<HTMLInputElement | null>(null)
// 列定义
const baseColumns = [
{prop: 'material_name', label: '名称'},
@ -558,17 +632,18 @@ const stockColumns = [
{prop: 'purchaser_email', label: '邮箱', minWidth: '150'},
{prop: 'source_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 STORAGE_KEY_COLS = 'stock_buy_visible_columns'
// 确保 arrival_photo 和 inspection_report 在默认列中
const defaultColumns = [
'material_name', 'category', 'material_type', 'spec_model', 'unit',
'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 = () => {
@ -598,7 +673,9 @@ const form = reactive({
warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '',
source_link: '', detail_link: '', arrival_photo: ''
source_link: '', detail_link: '',
arrival_photo: '',
inspection_report: '' // 新增字段
})
// ------------------------------------
@ -611,23 +688,20 @@ const HISTORY_KEYS = {
MATERIAL: 'history_materials'
}
// 保存历史 (String 类型)
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) // 最多存20条
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)
@ -638,7 +712,6 @@ const getHistoryList = (key: string): any[] => {
}
}
// 保存物料历史 (Object 类型)
const saveMaterialHistory = (item: any) => {
if (!item || !item.id) return
const key = HISTORY_KEYS.MATERIAL
@ -664,7 +737,7 @@ const getMaterialHistory = () => {
// ------------------------------------
// Autocomplete 建议逻辑 (混合模式:历史+当前表格)
// Autocomplete & Search Logic
// ------------------------------------
const createFilter = (queryString: string) => {
return (item: any) => {
@ -672,40 +745,31 @@ const createFilter = (queryString: string) => {
}
}
// 辅助函数:从当前表格提取
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 querySearchSupplier = (qs: string, cb: any) => mixedSearch(qs, 'supplier_name', HISTORY_KEYS.SUPPLIER, cb)
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 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 handleEmailSelect = (item: any) => saveToHistory(HISTORY_KEYS.EMAIL, item.value)
// 4. 币种 (固定+过滤)
const currencyOptions = [
{value: 'CNY', desc: '人民币'},
{value: 'USD', desc: '美元'},
@ -717,9 +781,8 @@ const querySearchCurrency = (queryString: string, cb: any) => {
}
// ------------------------------------
// 物料搜索逻辑 (优化:支持空查询加载默认值)
// Material Search Logic
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible) {
if (materialOptions.value.length === 0) {
@ -751,7 +814,6 @@ 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.category = item.category
@ -762,7 +824,7 @@ const onMaterialSelected = (val: number) => {
}
// ------------------------------------
// 逻辑校验规则
// Validation Logic
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback()
@ -797,7 +859,7 @@ const rules = {
}
// ------------------------------------
// 核心逻辑函数
// Business Logic: Batch/SN Mode
// ------------------------------------
const checkHistoryAndSetMode = async (baseId: number) => {
try {
@ -884,17 +946,36 @@ const handleUpdate = (row: any) => {
resetForm()
modeLocked.value = true
form.id = row.id
form.base_id = row.base_id
form.material_name = row.material_name
form.spec_model = row.spec_model
form.category = row.category
form.unit = row.unit
form.material_type = row.material_type
form.sku = row.sku
form.barcode = row.barcode
form.in_date = row.inbound_date
form.warehouse_location = row.warehouse_loc
// 映射所有字段,包括新增的 inspection_report
Object.assign(form, {
id: row.id,
base_id: row.base_id,
material_name: row.material_name,
spec_model: row.spec_model,
category: row.category,
unit: row.unit,
material_type: row.material_type,
sku: row.sku,
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) {
entryMode.value = 'serial'
@ -906,23 +987,6 @@ const handleUpdate = (row: any) => {
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 = [{
id: row.base_id,
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 () => {
if (!formRef.value) return
@ -947,8 +1126,7 @@ const submitForm = async () => {
const res: any = await createBuyInbound(form)
ElMessage.success('入库成功')
// 2. 自动打印 (使用返回的完整数据)
// res.data 包含了 newly created stock item, with generated ID and SKU
// 2. 自动打印
const newItem = res.data
if (newItem) {
ElMessage.info('正在发送打印指令...')
@ -1013,7 +1191,7 @@ const handlePrint = async (row: any) => {
warehouse_loc: row.warehouse_loc,
serial_number: row.serial_number,
batch_number: row.batch_number,
sku: row.sku // 【重要】显式增加 SKU 字段传递给后端
sku: row.sku
}
currentPrintData.value = printData
@ -1052,7 +1230,9 @@ const resetForm = () => {
warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '',
source_link: '', detail_link: '', arrival_photo: ''
source_link: '', detail_link: '',
arrival_photo: '',
inspection_report: '' // 重置新增字段
})
}
@ -1339,4 +1519,70 @@ onMounted(() => fetchData())
.empty-preview {
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>