Files
KCGL/inventory-web/src/views/stock/inbound/product.vue
dxc 8cae6ee7f6 refactor: remove local history caching and add API suggestions
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-10 14:38:15 +08:00

712 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="product-module">
<div class="header-tools">
<div class="left-tools">
<el-input
v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / SN / 工单 / 订单号..."
class="search-input"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 300px; margin-right: 10px;"
>
<template #append><el-button :icon="Search" @click="fetchData" /></template>
</el-input>
<el-select
v-model="queryParams.statuses"
multiple
collapse-tags
placeholder="状态筛选"
style="width: 220px;"
@change="fetchData"
>
<el-option label="在库" value="在库" />
<el-option label="借库" value="借库" />
<el-option label="已出库" value="已出库" />
</el-select>
</div>
<div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">成品入库登记</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10">
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
</el-row>
</el-checkbox-group>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
class="modern-table"
header-cell-class-name="table-header-gray"
>
<template v-for="col in allColumns" :key="col.prop">
<el-table-column
v-if="visibleColumnProps.includes(col.prop)"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '110'"
show-overflow-tooltip
>
<template #default="scope" v-if="col.prop === 'material_name'">
<span class="clickable-text" @click="handleUpdate(scope.row)">
{{ scope.row.material_name }}
</span>
</template>
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
<div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span>
<span class="id-text">{{ scope.row.serial_number }}</span>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'">
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>{{ scope.row.status }}</el-tag>
</template>
<template #default="scope" v-else-if="col.prop === 'quality_status'">
<el-tag :type="getQualityType(scope.row.quality_status)" effect="dark" size="small">{{ scope.row.quality_status }}</el-tag>
</template>
<template #default="scope" v-else-if="['product_photo', 'quality_report_link', 'inspection_report_link'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image
style="width: 40px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6; cursor: zoom-in;"
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
>
<template #error>
<div class="image-slot"><el-icon><Picture /></el-icon></div>
</template>
</el-image>
<span v-if="getImagesOnly(scope.row[col.prop]).length > 1" class="more-images-badge">+{{getImagesOnly(scope.row[col.prop]).length}}</span>
</div>
<div v-else-if="hasExternalLink(scope.row[col.prop])" style="text-align: center;">
<el-tag size="small" type="info"><el-icon><Link /></el-icon> 链接</el-tag>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="['detail_link'].includes(col.prop)">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
<el-icon><Link /></el-icon> 查看
</el-link>
</template>
<template #default="scope" v-else-if="['sale_price', 'raw_material_cost', 'manual_cost'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon>
</el-button>
<el-button link type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog">
<div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card">
<div class="card-title"><el-icon class="icon"><Box /></el-icon><span>1. 基础信息</span></div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10">
<el-form-item label="物料搜索" prop="base_id">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
placeholder="搜名称/规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div class="option-item">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view" /></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title"><el-icon class="icon"><House /></el-icon><span>2. 入库详情</span></div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
<div class="identity-panel">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="序列号(SN)" prop="serial_number">
<el-input v-model="form.serial_number" placeholder="必填: 唯一序列号" clearable><template #prefix><span class="prefix-tag sn">SN</span></template></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="24" style="margin-top:15px">
<template v-if="dialogStatus === 'update'">
<el-col :span="6">
<el-form-item label="库存状态" prop="status">
<el-select v-model="form.status" style="width:100%">
<el-option label="在库" value="在库"/>
<el-option label="借库" value="借库"/>
<el-option label="已出库" value="已出库"/>
</el-select>
</el-form-item>
</el-col>
</template>
<el-col :span="6">
<el-form-item label="质量状态">
<el-select v-model="form.quality_status" style="width:100%">
<el-option label="合格" value="合格" /><el-option label="不合格" value="不合格" /><el-option label="待检" value="待检" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="form.product_photo" style="display:none" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="quality_url" placeholder="外部链接..." style="margin-top:8px" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.quality_report_link" style="display:none" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="inspection_url" placeholder="外部链接..." style="margin-top:8px" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.inspection_report_link" style="display:none" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<div class="form-card production-card">
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" /></el-form-item></el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="订单号"><el-input v-model="form.order_id" placeholder="关联销售订单" /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="负责人">
<el-autocomplete v-model="form.production_manager" :fetch-suggestions="querySearchManager" placeholder="输入或选择负责人" style="width: 100%" clearable :trigger-on-focus="true" @select="handleManagerSelect"/>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="产品定价"><el-input-number v-model="form.sale_price" :precision="2" style="width:100%"><template #prefix>¥</template></el-input-number></el-form-item></el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="生产时间">
<el-date-picker v-model="form.production_time_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="原料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="人工成本"><el-input-number v-model="form.manual_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="24" style="margin-top:10px">
<el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" /></el-form-item></el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
{{ dialogStatus === 'create' ? '提交并打印' : '保存修改' }}
</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false"
/>
</el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div>
</div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"><p>打印机 IP: 192.168.9.205</p><p>尺寸: 40mm x 30mm</p></div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="printVisible = false">取消</el-button>
<el-button type="primary" :loading="printing" @click="confirmPrint">
<el-icon><Printer/></el-icon> 确认打印
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
// 修复点:引入 ElLoading
import { ElMessage, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { getLabelPreview, executePrint } from '@/api/common/print'
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
const searchLoading = ref(false)
const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] })
const materialOptions = ref<any[]>([])
// 打印相关变量
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const currentPrintData = ref<any>({})
// 图片/拍照相关
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
// 3个独立的列表
const productPhotoList = ref<any[]>([]) // 成品实拍
const qualityFileList = ref<any[]>([]) // 质量报告
const inspectionFileList = ref<any[]>([]) // 检测报告
const cameraDialogVisible = ref(false)
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
// 定义当前触发拍照的字段
const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo')
const quality_url = ref('')
const inspection_url = ref('')
// [核心优化] 所有列定义
const allColumns = [
{ prop: 'material_name', label: '名称', minWidth: '140' },
{ prop: 'sku', label: 'SKU', minWidth: '110' },
{ prop: 'serial_number', label: '序列号', minWidth: '130' },
{ prop: 'qty_stock', label: '库存', minWidth: '90' },
{ prop: 'status', label: '状态', minWidth: '90' },
{ prop: 'quality_status', label: '质量', minWidth: '90' },
{ prop: 'spec_model', label: '规格', minWidth: '120' },
{ prop: 'unit', label: '单位', minWidth: '80' },
{ prop: 'product_photo', label: '实拍图', minWidth: '100' },
{ prop: 'sale_price', label: '售价', minWidth: '100' },
{ prop: 'order_id', label: '订单号', minWidth: '120' },
{ prop: 'work_order_code', label: '工单号', minWidth: '120' },
{ prop: 'quality_report_link', label: '质量报告', minWidth: '100' },
{ prop: 'inspection_report_link', label: '检测报告', minWidth: '100' },
{ prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'production_manager', label: '负责人', minWidth: '100' },
{ prop: 'raw_material_cost', label: '原料成本', minWidth: '100' },
{ prop: 'manual_cost', label: '人工成本', minWidth: '100' },
{ prop: 'inbound_date', label: '生产日期', minWidth: '120' },
{ prop: 'detail_link', label: '详情', minWidth: '100' }
]
const defaultVisibleCols = ['material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const STORAGE_KEY = 'stock_product_visible_columns_v2'
const getSavedColumns = () => { try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : defaultVisibleCols } catch (e) { return defaultVisibleCols } }
const visibleColumnProps = ref(getSavedColumns())
watch(visibleColumnProps, (newVal) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal)) }, { deep: true })
const form = reactive({
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '',
sku: '', barcode: '', serial_number: '', in_date: '',
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
warehouse_location: '', status: '在库', quality_status: '合格',
bom_code: '', bom_version: '', work_order_code: '', order_id: '',
production_manager: '', production_time_range: [] as string[],
raw_material_cost: 0, manual_cost: 0, sale_price: 0,
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
})
// ------------------------------------
// 校验规则 (前端 pre-check)
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback()
// 简单的列表前端查重
const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true
return false
})
if (isDuplicate) callback(new Error('当前列表页存在相同SN(后端将进行全局校验)'))
else callback()
}
const rules = {
base_id: [{ required: true, message: '必选', trigger: 'change' }],
serial_number: [{ required: true, message: '必填', trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
}
const HISTORY_KEYS = { PRODUCTION_MANAGER: 'history_product_managers', MATERIAL: 'history_product_materials' }
const saveToHistory = (key: string, value: string) => { if (!value) return; try { const existing = localStorage.getItem(key); let list = existing ? JSON.parse(existing) : []; list = list.filter((i: string) => i !== value); list.unshift(value); if (list.length > 20) list = list.slice(0, 20); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
const getHistoryList = (key: string): any[] => { try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({ value: v })) } catch (e) { return [] } }
const saveMaterialHistory = (item: any) => { if (!item || !item.id) return; const key = HISTORY_KEYS.MATERIAL; try { let list = JSON.parse(localStorage.getItem(key) || '[]'); list = list.filter((i: any) => i.id !== item.id); list.unshift({ ...item, isHistory: true }); if (list.length > 10) list = list.slice(0, 10); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
const getMaterialHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] } }
// ------------------------------------
// Material Search & Population Logic
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query)
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
} finally { searchLoading.value = false }
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
// Auto-populate readonly fields
form.material_name = item.name
form.spec_model = item.spec
form.material_type = item.type
form.category = item.category
form.unit = item.unit
}
}
// ------------------------------------
// Autocomplete (Manager)
// ------------------------------------
const createFilter = (qs: string) => { return (item: any) => (item.value.toLowerCase().indexOf(qs.toLowerCase()) === 0) }
const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({ value: i })) }
const mixedSearch = (qs: string, tableField: string, storageKey: string, cb: any) => { const tableList = getTableDataUnique(tableField); const historyList = getHistoryList(storageKey); const map = new Map(); historyList.forEach(i => map.set(i.value, i)); tableList.forEach(i => map.set(i.value, i)); const allList = Array.from(map.values()); const results = qs ? allList.filter(createFilter(qs)) : allList; cb(results) }
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
const fetchData = async () => {
loading.value = true;
try {
const params = { ...queryParams, statuses: queryParams.statuses.join(',') }
const res: any = await getProductList(params);
tableData.value = res.data.items || [];
total.value = res.data.total || 0
} finally { loading.value = false }
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true
materialOptions.value = []
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
Object.assign(form, {
...row,
product_photo: row.product_photo || [],
quality_report_link: row.quality_report_link || [],
inspection_report_link: row.inspection_report_link || [],
in_quantity: Number(row.qty_inbound),
raw_material_cost: Number(row.raw_material_cost),
manual_cost: Number(row.manual_cost),
sale_price: Number(row.sale_price)
})
if(row.production_start_time && row.production_end_time) { form.production_time_range = [row.production_start_time, row.production_end_time] } else { form.production_time_range = [] }
productPhotoList.value = form.product_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qReports = form.quality_report_link || []
qualityFileList.value = qReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qLinks = qReports.filter(r => isExternalLink(r))
quality_url.value = qLinks.length > 0 ? qLinks[0] : ''
const iReports = form.inspection_report_link || []
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }]
visible.value = true
}
const getImageUrl = (url: string) => { if (!url) return ''; if (url.startsWith('http')) return url; return url }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { if (!list) return []; return list.filter(item => !isExternalLink(item)) }
const hasExternalLink = (list: string[]) => { if (!list) return false; return list.some(item => isExternalLink(item)) }
const beforeAvatarUpload = (rawFile: any) => { if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') { ElMessage.error('仅支持 JPG/PNG'); return false } if (rawFile.size / 1024 / 1024 > 5) { ElMessage.error('图片不能超过 5MB'); return false } return true }
const customUpload = async (options: any, targetField: 'product_photo' | 'quality_report_link' | 'inspection_report_link') => {
const { file, onSuccess, onError } = options
const formData = new FormData(); formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; form[targetField].push(newUrl); ElMessage.success('上传成功'); onSuccess(res)
} else { ElMessage.error(res.msg || '上传失败'); onError(new Error(res.msg)) }
} catch (e) { ElMessage.error('网络错误'); onError(e) }
}
const handleRemoveImage = async (uploadFile: any, targetField: 'product_photo' | 'quality_report_link' | 'inspection_report_link') => {
try {
const urlToRemove = form[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form[targetField] = form[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) { const filename = urlToRemove.split('/').pop(); if (filename) await deleteFile(filename) }
ElMessage.success('已删除')
} catch (e) { console.error(e) }
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
const triggerCamera = (field: any) => {
currentCameraField.value = field;
cameraDialogVisible.value = true;
}
// ------------------------------------------------------------------------
// 修复核心:拍照上传回调逻辑
// ------------------------------------------------------------------------
const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false;
return;
}
const formData = new FormData();
formData.append('file', file);
// 使用 ElLoading.service 替代报错的 ElMessage.loading
const loadingMsg = ElLoading.service({
lock: true,
text: '照片处理中...',
background: 'rgba(0, 0, 0, 0.7)'
});
let success = false;
try {
const res: any = await uploadFile(formData);
if (res.code === 200) {
const newUrl = res.data.url;
const field = currentCameraField.value; // 根据触发时记录的字段
// 添加到表单数据
form[field].push(newUrl);
// 更新对应的显示列表
const previewItem = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
if (field === 'product_photo') {
productPhotoList.value.push(previewItem);
} else if (field === 'quality_report_link') {
qualityFileList.value.push(previewItem);
} else if (field === 'inspection_report_link') {
inspectionFileList.value.push(previewItem);
}
ElMessage.success('拍照上传成功');
success = true;
} else {
ElMessage.error(res.msg || '上传失败');
}
} catch (e) {
ElMessage.error('网络错误,上传失败');
} finally {
loadingMsg.close();
if (success) {
cameraDialogVisible.value = false;
}
}
};
const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => {
if(valid) {
submitting.value = true
const qList = [...form.quality_report_link]
const qImages = qList.filter(item => !isExternalLink(item))
if (quality_url.value && !qList.includes(quality_url.value)) qImages.push(quality_url.value)
else if (quality_url.value) qImages.push(quality_url.value)
const iList = [...form.inspection_report_link]
const iImages = iList.filter(item => !isExternalLink(item))
if (inspection_url.value && !iList.includes(inspection_url.value)) iImages.push(inspection_url.value)
else if (inspection_url.value) iImages.push(inspection_url.value)
const payload = { ...form, quality_report_link: qImages, inspection_report_link: iImages, production_start_time: form.production_time_range?.[0], production_end_time: form.production_time_range?.[1] }
delete payload.production_time_range
try {
if(dialogStatus.value === 'create') {
const res: any = await createProductInbound(payload)
ElMessage.success('入库成功')
const newItem = res.data
if (newItem) { ElMessage.info('发送打印...'); try { await executePrint(newItem); ElMessage.success('指令已发送') } catch (e: any) { ElMessage.warning('打印失败') } }
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
visible.value = false; fetchData()
} catch(e:any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
}
})
}
const handleDelete = async (row: any) => { try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch(e) { ElMessage.error('删除失败') } }
const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
}
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('已发送'); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: 0, manual_cost: 0, sale_price: 0, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
}
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => fetchData())
</script>
<style scoped>
.product-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
.right-tools { display: flex; gap: 10px; align-items: center; }
.modern-table { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
.stock-num { font-weight: bold; font-size: 15px; }
.form-card { background: #fff; border-radius: 8px; margin-bottom: 20px; border: 1px solid #e4e7ed; overflow: hidden; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.card-title .icon { font-size: 18px; }
.card-content { padding: 20px; }
.basic-card { border-left: 4px solid #409EFF; } .basic-card .icon { color: #409EFF; }
.inbound-card { border-left: 4px solid #67C23A; } .inbound-card .icon { color: #67C23A; }
.production-card { border-left: 4px solid #E6A23C; } .production-card .icon { color: #E6A23C; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; padding: 15px; margin: 10px 0; border-radius: 6px; }
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; padding: 0 5px; font-weight: bold; border-radius: 4px; }
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; }
.opt-spec { color: #8492a6; font-size: 12px; margin-right: 10px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background: #f5f7fa; border-bottom: 1px solid #dcdfe6; padding-left: 0; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 20px; border-top: 1px solid #ebeef5; }
.money-text { font-family: 'Consolas', monospace; color: #303133; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
.empty-preview { color: #909399; }
.more-images-badge { margin-left: 5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 6px; font-size: 12px; }
.clickable-text { color: #409EFF; cursor: pointer; font-weight: 500; text-decoration: underline; }
.id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; }
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
.camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; }
</style>