采购件图像上传初实现

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

@ -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>