采购件图像上传初实现
This commit is contained in:
@ -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:
|
||||
|
||||
@ -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 找不到模型的问题)
|
||||
# =========================================================
|
||||
|
||||
132
inventory-backend/app/api/v1/common/upload.py
Normal file
132
inventory-backend/app/api/v1/common/upload.py
Normal 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
|
||||
@ -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 ""
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -42,4 +42,22 @@ export function searchMaterialBase(keyword: string) {
|
||||
method: 'get',
|
||||
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'
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
Reference in New Issue
Block a user