Files
KCGL/inventory-web/src/views/stock/inbound/product.vue
2026-06-11 17:36:34 +08:00

1553 lines
73 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-select
v-model="queryParams.company"
placeholder="所属公司"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-input
v-model="queryParams.keyword"
placeholder="请输入搜索关键字"
class="filter-item-input"
clearable
@input="handleInputSearch"
@clear="fetchData"
style="width: 280px;"
>
<template #prepend>
<el-select v-model="queryParams.searchField" style="width: 90px" @change="fetchData">
<el-option label="全部" value="all" />
<el-option label="名称" value="name" />
<el-option label="规格" value="spec" />
<el-option label="序列号" value="serial_number" />
<el-option label="工单" value="work_order_code" />
</el-select>
</template>
</el-input>
<el-input
v-model="queryParams.sku"
placeholder="请输入SKU搜索..."
class="filter-item-input"
clearable
@input="handleInputSearch"
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 160px;"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-cascader
v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别"
class="filter-item-select"
clearable
filterable
style="width: 220px;"
@change="fetchData"
/>
<el-select
v-model="queryParams.material_type"
placeholder="类型"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-button type="primary" plain class="search-btn" @click="fetchData">搜索</el-button>
<el-button class="reset-btn" @click="resetQuery">重置</el-button>
<el-popover
placement="bottom"
width="600"
trigger="click"
v-model:visible="advancedFilterVisible"
>
<template #reference>
<el-button type="primary" plain>高级筛选</el-button>
</template>
<div style="padding: 10px;">
<div v-for="(cond, idx) in advancedConditions" :key="idx" style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
<el-select v-model="cond.field" placeholder="字段" style="width: 150px;" :teleported="false">
<el-option v-for="opt in fieldOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="cond.operator" placeholder="操作符" style="width: 120px;" :teleported="false">
<el-option v-for="opt in operatorOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-input v-model="cond.value" placeholder="值" style="flex: 1;" />
<el-button type="danger" size="small" @click="removeCondition(idx)" :disabled="advancedConditions.length === 1">删除</el-button>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 10px;">
<el-button type="primary" size="small" @click="addCondition">添加条件</el-button>
<div>
<el-button size="small" @click="resetAdvancedFilter">重置</el-button>
<el-button type="primary" size="small" @click="applyAdvancedFilter">应用</el-button>
</div>
</div>
</div>
</el-popover>
<el-select
v-model="queryParams.statuses"
multiple
collapse-tags
placeholder="状态筛选"
style="width: 200px; margin-left: 10px;"
@change="fetchData"
>
<el-option label="在库" value="在库" />
<el-option label="借库" value="借库" />
<el-option label="已出库" value="已出库" />
</el-select>
</div>
<div class="right-tools">
<el-button v-if="userStore.hasPermission('inbound_product:operation')" 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>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; margin-bottom: 10px; padding-bottom: 10px;">
<span style="font-weight: bold;">列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10">
<template v-for="c in allColumns" :key="c.prop">
<el-col :span="8" v-if="hasColumnPermission(c.prop)">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</template>
</el-row>
</el-checkbox-group>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="displayData"
border
stripe
style="width: 100%"
class="modern-table"
highlight-current-row
header-cell-class-name="table-header-gray"
@sort-change="handleSortChange"
:key="hasColumnPermission('sn_bn') ? 'sn' : 'nosn'"
height="calc(100vh - 300px)"
>
<template v-for="col in allColumns" :key="col.prop">
<el-table-column
v-if="visibleColumnProps.includes(col.prop) && hasColumnPermission(col.prop)"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '110'"
:sortable="col.sortable ? 'custom' : false"
show-overflow-tooltip
>
<template #default="scope" v-if="col.prop === 'material_name'">
<span v-if="userStore.hasPermission('inbound_product:operation')" class="clickable-text" @click="handleUpdate(scope.row)">
{{ scope.row.material_name }}
</span>
<span v-else>{{ scope.row.material_name }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'company_name'">
<span>{{ scope.row.company_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
hide-on-click-modal
fit="cover"
lazy
>
<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', 'unit_total_cost', 'total_price'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column v-if="userStore.hasPermission('inbound_product:operation')" 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-button link type="danger" @click="handleDelete(row)">删除</el-button>
</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"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchData"
@current-change="fetchData"
/>
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="false" :close-on-press-escape="!isUploading" :show-close="!isUploading" class="stylish-dialog compact-layout">
<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">
<div style="display: flex; align-items: center; gap: 8px;">
<el-icon class="icon"><Box /></el-icon>
<span>1. 基础信息</span>
<el-link
v-if="form.base_id"
type="primary"
:underline="false"
style="font-size: 13px;"
@click="openMaterialInNewTab"
>
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
</el-link>
</div>
</div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-autocomplete
v-model="materialNameInput"
:fetch-suggestions="fetchMaterialSuggestions"
:value-key="'name'"
clearable
placeholder="请输入名称或规格进行检索..."
:loading="searchLoading"
:trigger-on-focus="true"
style="width: 100%"
@select="onMaterialSelected"
@clear="onMaterialClear"
popper-class="product-dropdown"
>
<template #prefix><el-icon><Search /></el-icon></template>
<template #default="{ item }">
<div class="option-item">
<div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span>
</div>
<div class="opt-meta">
<span class="opt-spec" :title="item.spec">{{ item.spec || '-' }}</span>
</div>
<div class="opt-tags">
<el-tag size="small" type="info" effect="light" class="company-tag">{{ item.company_name }}</el-tag>
<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>
</div>
</template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="12" 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="所属公司" v-if="hasFormFieldPermission('company_name')"><el-input v-model="form.company_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="名称" v-if="hasFormFieldPermission('material_name')"><el-input v-model="form.material_name" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格" v-if="hasFormFieldPermission('spec_model')"><el-input v-model="form.spec_model" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位" v-if="hasFormFieldPermission('unit')"><el-input v-model="form.unit" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" v-if="hasFormFieldPermission('material_type')"><el-input v-model="form.material_type" readonly class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别" v-if="hasFormFieldPermission('category')"><el-input v-model="form.category" readonly 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="warehouse_location">
<WarehouseSelector
v-model="form.warehouse_location"
:options="warehouseOptions"
/>
</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 prop="serial_number">
<template #label>
<div class="flex items-center justify-between w-full">
<span>序列号(SN)</span>
<el-link type="primary" :underline="false" @click="openScanner" title="开启摄像头智能扫码">
<el-icon><Camera /></el-icon> 智能扫码
</el-link>
</div>
</template>
<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-col :span="12" v-if="dialogStatus === 'create'">
<el-form-item label="打印份数" prop="print_copies">
<el-input-number v-model="form.print_copies" :min="1" :max="999" 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" id="upload-product_photo">
<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" :before-remove="handleBeforeRemove">
<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" id="upload-quality_report_link">
<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" :before-remove="handleBeforeRemove">
<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" id="upload-inspection_report_link">
<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" :before-remove="handleBeforeRemove">
<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>
<el-link type="success" :underline="false" style="margin-left: 15px; font-size: 13px;" @click="createBomForMaterial">
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="BOM编号">
<el-select
v-model="form.bom_code"
filterable
remote
reserve-keyword="true"
clearable
:disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom"
:loading="bomSearchLoading"
@change="handleBomSelect"
default-first-option="true"
style="width: 100%"
>
<el-option
v-for="item in bomOptions"
:key="`${item.bom_no}_${item.version}`"
:label="item.bom_no"
:value="`${item.bom_no}###${item.version}`"
>
<span style="float: left; font-weight: bold;">{{ item.bom_no }}</span>
<span style="float: right; color: #8492a6; font-size: 13px; margin-left: 10px;">
{{ item.version }} <span v-if="item.parent_spec">({{ item.parent_spec }})</span>
</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" placeholder="自动填充" readonly/></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" :controls="false" style="width:100%" placeholder="请输入">
<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-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="原料成本">
<el-input-number v-model="form.raw_material_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="单件成本">
<el-input-number v-model="form.unit_total_cost" :precision="2" :controls="false" style="width:100%" placeholder="请输入"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="总成本">
<el-input-number v-model="form.total_price" :precision="2" :controls="false" style="width:100%" placeholder="自动计算" disabled/>
</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" :disabled="isUploading">取消</el-button>
<el-button type="primary" :loading="submitting || isUploading" @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%" :close-on-click-modal="false" :close-on-press-escape="false">
<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" :close-on-press-escape="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 :close-on-click-modal="false" :close-on-press-escape="false">
<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 style="margin-top: 15px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<span style="font-weight: bold; color: #303133;">打印份数:</span>
<el-input-number v-model="printCopies" :min="1" :max="100" size="default" style="width: 120px;" />
</div>
</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>
<!-- 智能扫码弹窗 -->
<SmartScannerDialog v-model="scannerDialogVisible" @confirm="handleScannerConfirm" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
import request from '@/utils/request'
import {
getProductList,
createProductInbound,
updateProductInbound,
deleteProductInbound,
searchMaterialBase,
searchBom,
getFilterOptions,
getManagerHistory, // [新增]
calculateBomCost
} from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
import SmartScannerDialog from '@/components/SmartScannerDialog.vue'
import { getLabelPreview, executePrint } from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import { usePasteUpload } from '@/hooks/usePasteUpload'
import { useUserStore } from '@/stores/user'
// ------------------------------------
// 防抖函数
// ------------------------------------
const debounce = (fn: Function, delay: number = 500) => {
let timer: ReturnType<typeof setTimeout> | null = null
return (...args: any[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
// ------------------------------------
const userStore = useUserStore()
const router = useRouter()
// 在新标签页打开基础信息编辑
const openMaterialInNewTab = () => {
if (!form.base_id) return ElMessage.warning('请先选择物料')
const routeUrl = router.resolve({
path: '/material',
query: {
edit_id: form.base_id,
keyword: form.spec_model || form.material_name || ''
}
})
window.open(routeUrl.href, '_blank')
}
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 isUploading = ref(false)
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
const categoryOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const fieldOptions = computed(() => {
const allFields = [
{ value: 'id', label: 'ID', perm: 'inbound_product:id' },
{ value: 'base_id', label: 'BaseID', perm: 'inbound_product:base_id' },
{ value: 'company_name', label: '所属公司', perm: 'inbound_product:company_name' },
{ value: 'material_name', label: '名称', perm: 'inbound_product:material_name' },
{ value: 'spec_model', label: '规格', perm: 'inbound_product:spec_model' },
{ value: 'category', label: '类别', perm: 'inbound_product:category' },
{ value: 'material_type', label: '类型', perm: 'inbound_product:material_type' },
{ value: 'unit', label: '单位', perm: 'inbound_product:unit' },
{ value: 'sku', label: 'SKU', perm: 'inbound_product:sku' },
{ value: 'inbound_date', label: '入库日期', perm: 'inbound_product:inbound_date' },
{ value: 'barcode', label: '条码', perm: 'inbound_product:barcode' },
{ value: 'serial_number', label: '序列号(SN)', perm: 'inbound_product:serial_number' },
{ value: 'batch_number', label: '批号', perm: 'inbound_product:serial_number' },
{ value: 'status', label: '库存状态', perm: 'inbound_product:status' },
{ value: 'quality_status', label: '质量状态', perm: 'inbound_product:quality_status' },
{ value: 'in_quantity', label: '入库数量', perm: 'inbound_product:in_quantity' },
{ value: 'stock_quantity', label: '库存数', perm: 'inbound_product:stock_quantity' },
{ value: 'available_quantity', label: '可用数', perm: 'inbound_product:available_quantity' },
{ value: 'warehouse_location', label: '库位', perm: 'inbound_product:warehouse_location' },
{ value: 'bom_code', label: 'BOM编号', perm: 'inbound_product:bom_code' },
{ value: 'bom_version', label: 'BOM版本', perm: 'inbound_product:bom_version' },
{ value: 'work_order_code', label: '工单号', perm: 'inbound_product:work_order_code' },
{ value: 'raw_material_cost', label: '原料成本', perm: 'inbound_product:raw_material_cost' },
{ value: 'unit_total_cost', label: '单件成本', perm: 'inbound_product:unit_total_cost' },
{ value: 'total_price', label: '总成本', perm: 'inbound_product:total_price' },
{ value: 'order_id', label: '订单号', perm: 'inbound_product:order_id' },
{ value: 'sale_price', label: '产品定价', perm: 'inbound_product:sale_price' },
{ value: 'production_manager', label: '负责人', perm: 'inbound_product:production_manager' }
]
// 根据用户权限过滤
return allFields.filter(item => userStore.hasPermission(item.perm))
})
const operatorOptions = ref([
{ label: '等于', value: '=' },
{ label: '不等于', value: '!=' },
{ label: '包含', value: 'like' },
{ label: '不包含', value: 'not_like' },
{ label: '大于', value: '>' },
{ label: '小于', value: '<' },
{ label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' },
])
const materialNameInput = ref('')
// BOM 搜索相关
const bomSearchLoading = ref(false)
const bomOptions = ref<any[]>([])
// 打印相关变量
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const printCopies = ref(1)
const currentPrintData = ref<any>({})
// 图片/拍照相关
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
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 scannerDialogVisible = ref(false)
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
// ================= 第一步:声明基础数据 =================
// [核心优化] 所有列定义
const allColumns = [
{ prop: 'company_name', label: '所属公司', minWidth: '100', sortable: true }, // [新增]
{ prop: 'material_name', label: '名称', minWidth: '140', sortable: true },
{ prop: 'sku', label: 'SKU', minWidth: '110', sortable: true },
{ prop: 'warehouse_loc', label: '库位', minWidth: '120', sortable: true },
{ prop: 'serial_number', label: '序列号', minWidth: '130', sortable: true },
{ prop: 'qty_stock', label: '库存', minWidth: '90', sortable: true },
{ prop: 'status', label: '状态', minWidth: '90', sortable: true },
{ prop: 'quality_status', label: '质量', minWidth: '90', sortable: true },
{ prop: 'spec_model', label: '规格', minWidth: '120', sortable: true },
{ prop: 'unit', label: '单位', minWidth: '80', sortable: true },
{ prop: 'product_photo', label: '实拍图', minWidth: '100', sortable: false },
{ prop: 'sale_price', label: '售价', minWidth: '100', sortable: true },
{ prop: 'order_id', label: '订单号', minWidth: '120', sortable: true },
{ prop: 'work_order_code', label: '工单号', minWidth: '120', sortable: true },
{ prop: 'quality_report_link', label: '质量报告', minWidth: '100', sortable: false },
{ prop: 'inspection_report_link', label: '检测报告', minWidth: '100', sortable: false },
{ prop: 'bom_code', label: 'BOM', minWidth: '100', sortable: true },
{ prop: 'production_manager', label: '负责人', minWidth: '100', sortable: true },
{ prop: 'raw_material_cost', label: '原料成本', minWidth: '100', sortable: true },
{ prop: 'unit_total_cost', label: '单件成本', minWidth: '100', sortable: true },
{ prop: 'total_price', label: '总成本', minWidth: '100', sortable: true },
{ prop: 'inbound_date', label: '生产日期', minWidth: '120', sortable: true },
{ prop: 'detail_link', label: '详情', minWidth: '100', sortable: false }
]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'inbound_product:id',
base_id: 'inbound_product:base_id',
company_name: 'inbound_product:company_name',
material_name: 'inbound_product:material_name',
spec_model: 'inbound_product:spec_model',
category: 'inbound_product:category',
material_type: 'inbound_product:material_type',
unit: 'inbound_product:unit',
sku: 'inbound_product:sku',
inbound_date: 'inbound_product:inbound_date',
barcode: 'inbound_product:barcode',
serial_number: 'inbound_product:serial_number',
sn_bn: 'inbound_product:sn_bn',
batch_number: 'inbound_product:serial_number',
status: 'inbound_product:status',
quality_status: 'inbound_product:quality_status',
in_quantity: 'inbound_product:in_quantity',
qty_stock: 'inbound_product:qty_stock',
stock_quantity: 'inbound_product:stock_quantity',
qty_available: 'inbound_product:qty_available',
available_quantity: 'inbound_product:available_quantity',
warehouse_location: 'inbound_product:warehouse_location',
bom_code: 'inbound_product:bom_code',
bom_version: 'inbound_product:bom_version',
work_order_code: 'inbound_product:work_order_code',
raw_material_cost: 'inbound_product:raw_material_cost',
unit_total_cost: 'inbound_product:unit_total_cost',
total_price: 'inbound_product:total_price',
order_id: 'inbound_product:order_id',
sale_price: 'inbound_product:sale_price',
production_manager: 'inbound_product:production_manager',
product_photo: 'inbound_product:product_photo',
quality_report_link: 'inbound_product:quality_report_link',
inspection_report_link: 'inbound_product:inspection_report_link',
detail_link: 'inbound_product:detail_link',
}
// ================= 第二步:声明响应式变量 =================
const visibleColumnProps = ref<string[]>([])
// ================= 第三步:按依赖顺序放置方法和监听 =================
// 1. 获取唯一缓存 Key
const getStorageKey = () => `MOM_INBOUND_PROD_COLS_${userStore.username || 'DEFAULT'}`;
// 2. 检查列权限(依赖 permissionMap
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
// 3. 初始化列权限(依赖 allColumns / hasColumnPermission / getStorageKey
const initColumnPermissions = () => {
const allowedProps = allColumns
.filter(col => hasColumnPermission(col.prop))
.map(col => col.prop);
const cachedData = localStorage.getItem(getStorageKey());
if (cachedData) {
try {
const parsedCache = JSON.parse(cachedData);
visibleColumnProps.value = parsedCache.filter((prop: string) => allowedProps.includes(prop));
return;
} catch (e) {
console.error('解析列缓存失败', e);
}
}
visibleColumnProps.value = allowedProps;
};
// 4. 监听:只要用户勾选了列,就存入本地缓存
watch(visibleColumnProps, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
// 5. 全选功能的计算属性和事件
const isAllSelected = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length === allowedLength;
});
const isIndeterminate = computed(() => {
const allowedLength = allColumns.filter(c => hasColumnPermission(c.prop)).length;
return visibleColumnProps.value.length > 0 && visibleColumnProps.value.length < allowedLength;
});
const handleCheckAllChange = (val: boolean) => {
if (val) {
visibleColumnProps.value = allColumns.filter(c => hasColumnPermission(c.prop)).map(c => c.prop);
} else {
visibleColumnProps.value = [];
}
};
// ★ 智能聚合:当无序列号权限时,按 SKU 聚合库存
const displayData = computed(() => {
// 检查是否有序列号权限
const hasSnPermission = hasColumnPermission('sn_bn') || hasColumnPermission('serial_number')
if (hasSnPermission || !tableData.value.length) {
return tableData.value
}
// 无权限时,按 SKU + 规格型号聚合
const aggMap = new Map<string, any>()
for (const item of tableData.value) {
const key = `${item.sku || ''}_${item.spec_model || item.spec || ''}`
if (aggMap.has(key)) {
const existing = aggMap.get(key)
// 累加库存数量(原地修改已拷贝的对象,不影响原始数据)
existing.qty_stock = (existing.qty_stock || 0) + (item.qty_stock || 0)
existing.qty_available = (existing.qty_available || 0) + (item.qty_available || 0)
existing.stock_quantity = (existing.stock_quantity || 0) + (item.stock_quantity || 0)
existing.available_quantity = (existing.available_quantity || 0) + (item.available_quantity || 0)
existing.in_quantity = (existing.in_quantity || 0) + (item.in_quantity || 0)
// 累加其他数量字段
existing.total_price = (existing.total_price || 0) + (item.total_price || 0)
existing.raw_material_cost = (existing.raw_material_cost || 0) + (item.raw_material_cost || 0)
existing.unit_total_cost = (existing.unit_total_cost || 0) + (item.unit_total_cost || 0)
} else {
// 【关键修复】使用 Object.assign 深拷贝第一条记录的所有字段,
// 绝不能只保留 sku 和 qty否则其他列公司/名称/规格等)会读不到数据
aggMap.set(key, Object.assign({}, item))
}
}
return Array.from(aggMap.values())
})
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const form = reactive({
id: undefined, base_id: undefined as number | undefined,
company_name: '', // [新增]
material_name: '', spec_model: '', material_type: '', category: '', unit: '',
sku: '', barcode: '', serial_number: '', in_date: '',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 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: undefined as number | undefined,
unit_total_cost: undefined as number | undefined,
total_price: undefined as number | undefined,
sale_price: undefined as number | undefined,
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
})
// ------------------------------------
// BOM Search Logic
// ------------------------------------
const handleSearchBom = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
bomSearchLoading.value = true
try {
const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
const handleBomSelect = async (val: string) => {
if (!val) {
form.bom_code = ''
form.bom_version = ''
return
}
const [code, version] = val.split('###')
form.bom_code = code
form.bom_version = version
// 自动计算 BOM 成本并填入 raw_material_cost 和 unit_total_cost
try {
const res: any = await calculateBomCost({ bom_code: code, bom_version: version })
if (res.code === 200 && typeof res.data === 'number') {
form.raw_material_cost = res.data
form.unit_total_cost = res.data
}
} catch (e) {
// 计算失败不影响现有输入
console.warn('BOM 成本计算失败', e)
}
}
// ------------------------------------
// 表单字段权限检查
// ------------------------------------
const hasFormFieldPermission = (fieldName: string) => {
// 超级管理员直接返回true
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 根据字段名映射到权限码
const map: Record<string, string> = {
company_name: 'inbound_product:company_name',
material_name: 'inbound_product:material_name',
spec_model: 'inbound_product:spec_model',
material_type: 'inbound_product:material_type',
category: 'inbound_product:category',
unit: 'inbound_product:unit',
sku: 'inbound_product:sku',
barcode: 'inbound_product:barcode',
serial_number: 'inbound_product:serial_number',
in_date: 'inbound_product:inbound_date',
in_quantity: 'inbound_product:in_quantity',
stock_quantity: 'inbound_product:stock_quantity',
available_quantity: 'inbound_product:available_quantity',
warehouse_location: 'inbound_product:warehouse_location',
status: 'inbound_product:status',
quality_status: 'inbound_product:quality_status',
bom_code: 'inbound_product:bom_code',
bom_version: 'inbound_product:bom_version',
work_order_code: 'inbound_product:work_order_code',
order_id: 'inbound_product:order_id',
production_manager: 'inbound_product:production_manager',
production_time_range: 'inbound_product:production_start_time',
raw_material_cost: 'inbound_product:raw_material_cost',
manual_cost: 'inbound_product:manual_cost',
sale_price: 'inbound_product:sale_price',
quality_report_link: 'inbound_product:quality_report_link',
inspection_report_link: 'inbound_product:inspection_report_link',
product_photo: 'inbound_product:product_photo',
detail_link: 'inbound_product:detail_link',
}
const code = map[fieldName]
if (!code) {
// 没有映射的字段默认显示
return true
}
return userStore.hasPermission(code)
}
// ------------------------------------
// Validation Logic
// ------------------------------------
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' }],
warehouse_location: [{required: true, message: '库位不能为空,请填写或选择', trigger: ['blur', 'change']}]
}
// Material Search & Population Logic
// ------------------------------------
const fetchMaterialSuggestions = (query: string, cb: (results: any[]) => void) => {
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery).then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name, isHistory: false }))
cb(formatted)
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
const onMaterialClear = () => {
form.base_id = undefined
form.company_name = ''
form.material_name = ''
form.spec_model = ''
form.material_type = ''
form.category = ''
form.unit = ''
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
}
const onMaterialSelected = (item: any) => {
form.base_id = item.id
form.company_name = item.company_name
form.material_name = item.name
form.spec_model = item.spec
form.material_type = item.type
form.category = item.category
form.unit = item.unit
materialNameInput.value = item.name
// 切换物料时清空已选 BOM防止脏数据
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
// 获取该物料历史入库库位
request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
if (res.code === 200 && res.data?.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
}).catch(() => {})
}
// ------------------------------------
// Autocomplete (Manager) - 后端历史记录驱动 (已修改为全局)
// ------------------------------------
const querySearchManager = async (query: string, cb: any) => {
try {
const res: any = await getManagerHistory({ keyword: query })
if (res.code === 200) {
const managers = (res.data || []).map((name: string) => ({ value: name }))
cb(managers)
} else { cb([]) }
} catch (e) { cb([]) }
}
const handleManagerSelect = (item: any) => {
form.production_manager = item.value
}
const fetchData = async () => {
loading.value = true
try {
const params = {
...queryParams,
statuses: queryParams.statuses.join(','),
orderByColumn: queryParams.orderByColumn,
isAsc: queryParams.isAsc,
advancedFilters: queryParams.advancedFilters.length > 0 ? JSON.stringify(queryParams.advancedFilters) : ''
}
const res: any = await getProductList(params)
tableData.value = res.data.items || []
total.value = res.data.total || 0
} finally { loading.value = false }
}
// 防抖即时搜索
const debouncedSearch = debounce(() => {
queryParams.page = 1
fetchData()
}, 500)
const handleInputSearch = () => {
debouncedSearch()
}
const fetchOptions = async () => {
try {
const res: any = await getFilterOptions()
if (res.code === 200) {
categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
}
} catch (e) {
console.error("Fetch options failed", e)
}
}
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children as any[];
}
});
});
return root;
};
// 加载库位树数据
const loadWarehouseTree = async () => {
try {
const res = await getWarehouseTree()
if (res.code === 200) {
warehouseOptions.value = res.data || []
}
} catch (e) {
console.error('加载库位树失败', e)
}
}
const resetQuery = () => {
queryParams.keyword = ''
queryParams.searchField = 'all'
queryParams.sku = ''
queryParams.category = ''
queryParams.material_type = ''
queryParams.company = ''
queryParams.page = 1
queryParams.orderByColumn = ''
queryParams.isAsc = ''
queryParams.advancedFilters = []
fetchData()
}
const handleSortChange = ({ column, prop, order }: any) => {
if (order === 'ascending') {
queryParams.orderByColumn = prop
queryParams.isAsc = 'true'
} else if (order === 'descending') {
queryParams.orderByColumn = prop
queryParams.isAsc = 'false'
} else {
queryParams.orderByColumn = ''
queryParams.isAsc = ''
}
queryParams.page = 1
fetchData()
}
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
const validConditions = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
queryParams.advancedFilters = validConditions
advancedFilterVisible.value = false
queryParams.page = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
queryParams.advancedFilters = []
advancedFilterVisible.value = false
queryParams.page = 1
fetchData()
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true
}
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: (row.raw_material_cost !== null && row.raw_material_cost !== undefined) ? Number(row.raw_material_cost) : undefined,
unit_total_cost: (row.unit_total_cost !== null && row.unit_total_cost !== undefined) ? Number(row.unit_total_cost) : undefined,
sale_price: (row.sale_price !== null && row.sale_price !== undefined) ? Number(row.sale_price) : undefined
})
// 计算总成本
const u = Number(form.unit_total_cost || 0)
const q = Number(form.in_quantity || 1)
form.total_price = Number((u * q).toFixed(2))
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] : ''
materialNameInput.value = row.material_name
// 回显BOM
if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
}
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)
isUploading.value = true
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) }
finally { isUploading.value = false }
}
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 }
// 粘贴上传处理器PC 端:鼠标悬停 + Ctrl+V 直接粘贴图片)
const handlePasteLink = (link: string, field: string) => {
if (field === 'quality_report_link') quality_url.value = link
else if (field === 'inspection_report_link') inspection_url.value = link
}
usePasteUpload(customUpload, 'product_photo', '#upload-product_photo', handlePasteLink)
usePasteUpload(customUpload, 'quality_report_link', '#upload-quality_report_link', handlePasteLink)
usePasteUpload(customUpload, 'inspection_report_link', '#upload-inspection_report_link', handlePasteLink)
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);
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 openScanner = () => {
scannerDialogVisible.value = true
}
const handleScannerConfirm = (result: string) => {
form.serial_number = result
scannerDialogVisible.value = false
ElMessage.success('序列号已提取')
}
// 快速基于此物料创建 BOM
const createBomForMaterial = () => {
if (!form.base_id) return ElMessage.warning('请先锁定物料基础信息')
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.base_id,
parent_name: form.material_name,
parent_spec: form.spec_model
}
})
window.open(routeUrl.href, '_blank')
}
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,
raw_material_cost: Number(form.raw_material_cost || 0),
unit_total_cost: Number(form.unit_total_cost || 0),
total_price: Number(form.total_price || 0),
sale_price: Number(form.sale_price || 0),
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 { const printPayload = { ...newItem, warehouse_loc: form.warehouse_location || newItem.warehouse_location || newItem.warehouse_loc || '未分配', copies: form.print_copies }; await executePrint(printPayload); ElMessage.success(`指令已发送 (x${form.print_copies})`) } catch (e: any) { ElMessage.warning('打印失败') } }
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
visible.value = false; fetchData()
} catch(error:any) {
// 后端返回 HTTP 500 时(如物料类别隔离校验),从 axios 错误的 response.data.msg 提取具体报错
const errorMsg = error.response?.data?.msg || error.message || '系统内部错误,入库失败'
ElMessage.error(errorMsg)
} finally { submitting.value = false }
} else {
ElMessage.warning('入库校验未通过,请检查必填项(如:库位)是否已填写完整!')
}
})
}
const handleDelete = (row: any) => {
const recordName = row.sku || row.barcode || '此项';
ElMessageBox.confirm(
`是否确认删除成品入库记录 "${recordName}" ?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(async () => {
try {
await deleteProductInbound(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (e) {
ElMessage.error('删除失败');
}
}).catch(() => {});
};
// ==========================================
// 拦截图片/文件删除:弹出确认框
// ==========================================
const handleBeforeRemove = (uploadFile, uploadFiles) => {
return new Promise((resolve, reject) => {
ElMessageBox.confirm(
`确定要移除文件 "${uploadFile.name}" `,
'提示',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
).then(() => {
resolve(true);
}).catch(() => {
reject(false);
});
});
};
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_location || 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, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialNameInput.value = ''; bomOptions.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, print_copies: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, 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(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData()
fetchOptions()
loadWarehouseTree()
})
// 成本计算监听
watch([() => form.unit_total_cost, () => form.in_quantity], ([unit, qty]) => {
const unitNum = Number(unit || 0)
const qtyNum = Number(qty || 1)
form.total_price = Number((unitNum * qtyNum).toFixed(2))
})
</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); flex-wrap: wrap; }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; flex-wrap: wrap; }
.right-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.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; }
/* [重点] 下拉框 Flex 布局 */
.option-item { display: flex; align-items: center; padding: 8px 0; width: 100%; }
.opt-main { flex: 1; min-width: 0; margin-right: 10px; }
.opt-name { font-weight: 600; font-size: 14px; color: #333; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-meta { width: 100px; text-align: right; flex-shrink: 0; margin-right: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.opt-spec { color: #999; font-size: 12px; }
.opt-tags { display: flex; gap: 5px; flex-shrink: 0; }
.company-tag { font-weight: bold; }
/* [新增] 修复 filter-item-select/input 样式 */
.filter-item-select { /* 宽度已在行内样式控制 */ }
.filter-item-input { /* 宽度已在行内样式控制 */ }
.action-btn { font-weight: 500; }
.search-btn { background-color: #E6F1FC; border-color: #A3D0FD; color: #409EFF; }
.search-btn:hover { background-color: #409EFF; border-color: #409EFF; color: #fff; }
.reset-btn { background-color: #fff; border: 1px solid #dcdfe6; }
.reset-btn:hover { border-color: #c0c4cc; color: #606266; }
/* [新增] 修复弹窗最小高度 */
.dialog-scroll-container { min-height: 450px; }
/* [新增] 纯文本样式 */
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: transparent !important; border-bottom: 1px dashed #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #303133; font-weight: 600; font-size: 14px; cursor: text; }
/* 左对齐数字框 */
:deep(.el-input-number .el-input__inner) { text-align: left; }
</style>
<style>
.product-dropdown { width: 580px !important; }
.product-dropdown .el-select-dropdown__wrap { max-height: 320px !important; }
.product-dropdown .el-input__suffix { z-index: 10; }
</style>