Files
KCGL/inventory-web/src/views/stock/inbound/product.vue

1377 lines
66 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-select
v-model="queryParams.category"
placeholder="类别"
class="filter-item-select"
clearable
filterable
@change="fetchData"
style="width: 160px;"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<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>
<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"
highlight-current-row
header-cell-class-name="table-header-gray"
@sort-change="handleSortChange"
>
<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'"
:sortable="col.sortable ? 'custom' : false"
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="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
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-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="min(1000px, 95vw)" top="5vh" :close-on-click-modal="!isUploading" :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;">
<el-icon class="icon"><Box /></el-icon>
<span>1. 基础信息</span>
</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-select
v-model="form.base_id"
filterable
remote
reserve-keyword
clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
v-loadmore="loadMoreMaterials"
popper-class="product-dropdown"
>
<template #prefix><el-icon><Search /></el-icon></template>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<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>
</el-option>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</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">
<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-select
v-model="form.bom_code"
filterable
remote
clearable
placeholder="搜规格/编号"
:remote-method="handleSearchBom"
:loading="bomSearchLoading"
@change="handleBomSelect"
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%">
<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 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 } from '@element-plus/icons-vue'
import { ElMessage, ElLoading } from 'element-plus'
import dayjs from 'dayjs'
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 { 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)
}
}
// ------------------------------------
// v-loadmore
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) {
binding.value()
}
})
}
}
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
}
}
const userStore = useUserStore()
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
const searchLoading = ref(false)
const loadingMore = 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: 50, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
const categoryOptions = ref<string[]>([])
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 materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// 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: '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',
batch_number: 'inbound_product:serial_number',
status: 'inbound_product:status',
quality_status: 'inbound_product:quality_status',
in_quantity: 'inbound_product:in_quantity',
stock_quantity: 'inbound_product:stock_quantity',
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',
}
// 根据用户权限初始化列显示状态
// 初始化列显示状态(移除权限限制,添加 localStorage 支持)
const initColumnPermissions = () => {
// 生成存储键使用用户ID或用户名如果没有则使用浏览器唯一标识
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_product_columns_${userId}`
// 尝试从 localStorage 读取保存的列配置
const savedColumns = localStorage.getItem(storageKey)
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns)
// 验证保存的列是否有效(存在于 allColumns 中)
const validColumns = parsed.filter((prop: string) =>
allColumns.some(col => col.prop === prop)
)
if (validColumns.length > 0) {
visibleColumnProps.value = validColumns
return
}
} catch (e) {
console.warn('Failed to parse saved columns:', e)
}
}
// 如果没有保存的配置,使用默认列
visibleColumnProps.value = defaultVisibleCols
}
// 检查列权限(移除权限限制,始终返回 true
const hasColumnPermission = (prop: string) => {
return true
}
const defaultVisibleCols = ['company_name', 'material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const visibleColumnProps = ref(defaultVisibleCols)
// 监听列配置变化并保存到 localStorage
watch(visibleColumnProps, (newVal) => {
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_product_columns_${userId}`
try {
localStorage.setItem(storageKey, JSON.stringify(newVal))
} catch (e) {
console.warn('Failed to save columns to localStorage:', e)
}
}, { deep: true })
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) => {
bomSearchLoading.value = true
try {
const res: any = await searchBom(query)
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 handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
searchKeyword.value = query
searchPage.value = 1
materialOptions.value = []
try {
const res: any = await searchMaterialBase(query, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.data.has_next
} else {
hasNextPage.value = false
}
} catch (e) {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
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
}
}
// ------------------------------------
// 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
typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增]
}
} catch (e) {
console.error("Fetch options failed", e)
}
}
// 加载库位树数据
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
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: (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] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
// 回显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 }
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('序列号已提取')
}
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 { await executePrint({ ...newItem, copies: form.print_copies }); ElMessage.success(`指令已发送 (x${form.print_copies})`) } 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, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.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>