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

1784 lines
75 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="buy-module">
<div class="header-container" style="flex-wrap: wrap;">
<div class="search-form-area" style="flex-wrap: wrap;">
<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
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 280px;"
>
<template #prepend>
<el-select v-model="queryParams.searchField" style="width: 90px">
<el-option label="全部" value="all" />
<el-option label="名称" value="name" />
<el-option label="规格" value="spec" />
<el-option label="条码" value="barcode" />
<el-option label="批号" value="batch_number" />
</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
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual">
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">高级筛选</el-button>
</template>
<div class="advanced-filter">
<div v-for="(condition, index) in advancedConditions" :key="index" class="condition-row" style="display: flex; align-items: center; margin-bottom: 10px;">
<el-select v-model="condition.field" placeholder="字段" style="width: 180px">
<el-option v-for="field in fieldOptions" :key="field.value" :label="field.label" :value="field.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width: 120px; margin-left: 8px">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width: 180px; margin-left: 8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left: 8px">删除</el-button>
</div>
<div style="margin-top: 12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button @click="applyAdvancedFilter" type="primary">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
</div>
<div class="right-actions" style="flex-wrap: wrap;">
<el-button v-if="userStore.hasPermission('inbound_buy:operation')" type="primary" :icon="Plus" @click="handleCreate" class="add-btn">新增</el-button>
<el-button :icon="Refresh" circle @click="fetchData" class="circle-btn" />
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference>
<el-button :icon="Setting" circle class="circle-btn" />
</template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :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 || '140'"
show-overflow-tooltip
:sortable="isColumnSortable(col.prop) ? 'custom' : false"
>
<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 === 'sn_bn'">
<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>
<div v-else-if="scope.row.batch_number" class="id-cell">
<span class="prefix-tag bn">BN</span>
<span class="id-text">{{ scope.row.batch_number }}</span>
</div>
<span v-else class="text-placeholder">-</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 === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'tax_rate'">
<span style="color: #909399;">{{ scope.row.tax_rate }}%</span>
</template>
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].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="col.prop.includes('link')">
<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="['unit_price', 'total_price'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop], scope.row.currency) }}</span>
</template>
</el-table-column>
</template>
<el-table-column v-if="userStore.hasPermission('inbound_buy:operation')" label="操作" width="220" 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" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default" v-permission="'inbound_buy:delete'">删除</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"
:page-sizes="[100, 200, 500, 1000]"
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="4vh"
destroy-on-close
:close-on-click-modal="false"
class="stylish-dialog compact-layout"
>
<div class="dialog-scroll-container">
<el-form :model="form" label-width="100px" 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>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</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="handleSearchMaterialDebounced"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
v-loadmore="loadMoreMaterials"
popper-class="long-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="warning" 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="20">
<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('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-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-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="20">
<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="in_date"><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-col :span="6">
<el-form-item label="库位" prop="warehouse_location">
<WarehouseSelector
v-model="form.warehouse_location"
:options="warehouseOptions"
/>
</el-form-item>
</el-col>
</el-row>
<div class="identity-panel">
<el-row>
<el-col :span="24" style="margin-bottom: 8px;">
<el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked" size="small" class="custom-radio-group">
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
</el-radio-group>
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 历史锁定 (同物料遵循历史模式)</span>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="批号" prop="batch_number">
<el-input v-model="form.batch_number" :placeholder="entryMode === 'batch' ? '系统生成...' : '不可用'" :disabled="entryMode === 'serial'" clearable>
<template #prefix><span class="prefix-tag bn">BN</span></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable>
<template #prefix><span class="prefix-tag sn">SN</span></template>
</el-input>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="20" style="margin-top: 10px;">
<el-col :span="6">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input" @change="updatePrices('qty')"/>
</el-form-item>
</el-col>
<el-col :span="6" v-if="dialogStatus === 'create'">
<el-form-item label="打印份数" prop="print_copies">
<el-input-number v-model="form.print_copies" :min="1" :max="999" controls-position="right" style="width:100%"/>
</el-form-item>
</el-col>
<template v-if="dialogStatus === 'update'">
<el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<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="到检状态" prop="inspection_status">
<el-select v-model="form.inspection_status" style="width:100%">
<el-option label="未检" value="未检"><span style="color:#909399">⚪ 未检</span></el-option>
<el-option label="合格" value="合格"><span style="color:#67C23A">🟢 合格</span></el-option>
<el-option label="不合格" value="不合格"><span style="color:#F56C6C">🔴 不合格</span></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container">
<el-upload
v-model:file-list="arrivalFileList"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'arrival_photo')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'arrival_photo')"
:before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="form.arrival_photo" placeholder="图片列表" style="display:none;" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="检测报告" prop="inspection_report">
<div class="upload-container">
<el-upload
v-model:file-list="reportFileList"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'inspection_report')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'inspection_report')"
:before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="inspection_report_url" placeholder="如有外部报告链接请在此输入 (选填)" style="margin-top: 8px;" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.inspection_report" placeholder="图片列表" style="display:none;" />
</el-form-item>
</el-col>
</el-row>
<div class="divider-text">商务与采购信息</div>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="币种">
<el-autocomplete v-model="form.currency" :fetch-suggestions="querySearchCurrency" placeholder="币种" style="width: 100%" :trigger-on-focus="true">
<template #default="{ item }"><span>{{ item.value }}</span><span style="float:right; color:#999; font-size:12px">{{ item.desc }}</span></template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="汇率">
<el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width:100%" @change="updatePrices('tax')">
<el-option label="0%" :value="0" />
<el-option label="1%" :value="1" />
<el-option label="13%" :value="13" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 15px;">
<el-col :span="8">
<el-form-item label="不含税单价" prop="unit_price">
<el-input-number
v-model="form.unit_price"
:precision="2"
:controls="false"
style="width:100%"
placeholder="请输入"
@change="updatePrices('pre')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="含税单价">
<el-input-number
v-model="form.post_tax_unit_price"
:precision="2"
:controls="false"
style="width:100%"
placeholder="请输入"
@change="updatePrices('post')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="不含税总价">
<el-input-number
v-model="form.total_price"
:precision="2"
disabled
:controls="false"
style="width:100%"
class="total-price-input"
placeholder="自动计算"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 15px;">
<el-col :span="8">
<el-form-item label="供应商">
<el-autocomplete
v-model="form.supplier_name"
:fetch-suggestions="querySearchSupplier"
placeholder="输入或选择供应商"
style="width: 100%"
clearable
:trigger-on-focus="true"
@select="handleSupplierSelect"
>
<template #default="{ item }">
<div style="font-weight: 500">{{ item.value }}</div>
</template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="采购人">
<el-autocomplete
v-model="form.purchaser"
:fetch-suggestions="querySearchPurchaser"
placeholder="输入采购人"
style="width: 100%"
clearable
:trigger-on-focus="true"
@select="handlePurchaserSelect"
>
<template #default="{ item }">
<div style="display: flex; justify-content: space-between;">
<span style="font-weight: 500">{{ item.value }}</span>
<span v-if="item.email" style="color: #999; font-size: 12px;">{{ item.email }}</span>
</div>
</template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="采购邮箱">
<el-input v-model="form.purchaser_email" placeholder="自动填充或手动输入" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="原始链接">
<el-autocomplete
v-model="form.source_link"
:fetch-suggestions="(qs, cb) => querySearchLinks(qs, cb, 'original')"
placeholder="http://"
style="width: 100%"
clearable
:trigger-on-focus="true"
>
<template #default="{ item }"><div style="font-size: 12px; line-height: 1.2; padding: 4px 0;">{{ item.value }}</div></template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="详情链接">
<el-autocomplete
v-model="form.detail_link"
:fetch-suggestions="(qs, cb) => querySearchLinks(qs, cb, 'detail')"
placeholder="http://"
style="width: 100%"
clearable
:trigger-on-focus="true"
>
<template #default="{ item }"><div style="font-size: 12px; line-height: 1.2; padding: 4px 0;">{{ item.value }}</div></template>
</el-autocomplete>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }}</el-button>
</div>
</template>
</el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false"
/>
</el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div>
</div>
<div style="margin-top: 20px; font-size: 14px; color: #666;">
<p>打印机 IP: 192.168.9.205</p>
<p>尺寸: 40mm x 30mm</p>
<div 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>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, onMounted, watch, computed} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox, ElLoading} from 'element-plus'
import dayjs from 'dayjs'
import {
getBuyList,
createBuyInbound,
updateBuyInbound,
deleteBuyInbound,
searchMaterialBase,
uploadFile,
deleteFile,
getSupplierSuggestions,
getUserSuggestions,
getLinkSuggestions,
getLocationSuggestions,
getFilterOptions
} from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print'
import { getWarehouseTree } from '@/api/common/warehouse'
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import WarehouseSelector from '@/components/WarehouseSelector.vue'
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 (适配 Teleport 到 Body 的下拉框)
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
const dropDownWrap = document.querySelector('.long-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 hasFormFieldPermission = (fieldName: string) => {
// 超级管理员直接返回true
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
// 根据字段名映射到权限码
const map: Record<string, string> = {
company_name: 'inbound_buy:company_name',
material_name: 'inbound_buy:material_name',
spec_model: 'inbound_buy:spec_model',
category: 'inbound_buy:category',
material_type: 'inbound_buy:material_type',
unit: 'inbound_buy:unit',
sku: 'inbound_buy:sku',
barcode: 'inbound_buy:barcode',
in_date: 'inbound_buy:in_date',
serial_number: 'inbound_buy:serial_number',
batch_number: 'inbound_buy:batch_number',
status: 'inbound_buy:status',
inspection_status: 'inbound_buy:inspection_status',
in_quantity: 'inbound_buy:in_quantity',
stock_quantity: 'inbound_buy:stock_quantity',
available_quantity: 'inbound_buy:available_quantity',
warehouse_location: 'inbound_buy:warehouse_location',
unit_price: 'inbound_buy:unit_price',
tax_rate: 'inbound_buy:tax_rate',
total_price: 'inbound_buy:total_price',
currency: 'inbound_buy:currency',
exchange_rate: 'inbound_buy:exchange_rate',
supplier_name: 'inbound_buy:supplier_name',
purchaser: 'inbound_buy:purchaser',
purchaser_email: 'inbound_buy:purchaser_email',
source_link: 'inbound_buy:original_link',
detail_link: 'inbound_buy:detail_link',
arrival_photo: 'inbound_buy:arrival_photo',
inspection_report: 'inbound_buy:inspection_report',
print_copies: 'inbound_buy:print_copies',
}
const code = map[fieldName]
if (!code) {
// 没有映射的字段默认显示
return true
}
return userStore.hasPermission(code)
}
// ------------------------------------
// 状态与变量
// ------------------------------------
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 categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([])
const queryParams = reactive({
page: 1,
pageSize: 50,
keyword: '',
searchField: 'all',
sku: '',
category: '',
material_type: '',
company: '',
statuses: ['在库', '借库'],
orderByColumn: '',
isAsc: undefined as string | undefined,
advancedFilters: [] as any[]
})
const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const currentPrintData = ref<any>({})
const printCopies = ref(1)
const entryMode = ref('batch')
const modeLocked = ref(false)
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
const arrivalFileList = ref<any[]>([])
const reportFileList = ref<any[]>([])
const cameraDialogVisible = ref(false)
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null)
const currentCameraField = ref<'arrival_photo' | 'inspection_report'>('arrival_photo')
const inspection_report_url = ref('')
// 库位级联选择器数据
const warehouseOptions = ref<any[]>([])
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const fieldOptions = computed(() => {
const allFields = [
{ value: 'company_name', label: '所属公司', perm: 'inbound_buy:company_name' },
{ value: 'material_name', label: '名称', perm: 'inbound_buy:material_name' },
{ value: 'material_type', label: '类型', perm: 'inbound_buy:material_type' },
{ value: 'category', label: '类别', perm: 'inbound_buy:category' },
{ value: 'spec_model', label: '规格型号', perm: 'inbound_buy:spec_model' },
{ value: 'unit', label: '单位', perm: 'inbound_buy:unit' },
{ value: 'sku', label: 'SKU', perm: 'inbound_buy:sku' },
{ value: 'barcode', label: '条码', perm: 'inbound_buy:barcode' },
{ value: 'batch_number', label: '批号', perm: 'inbound_buy:sn_bn' },
{ value: 'serial_number', label: '序列号', perm: 'inbound_buy:sn_bn' },
{ value: 'warehouse_location', label: '库位', perm: 'inbound_buy:warehouse_loc' },
{ value: 'status', label: '状态', perm: 'inbound_buy:status' },
{ value: 'inspection_status', label: '到检状态', perm: 'inbound_buy:inspection_status' },
{ value: 'qty_inbound', label: '入库量', perm: 'inbound_buy:qty_inbound' },
{ value: 'qty_stock', label: '库存数', perm: 'inbound_buy:qty_stock' },
{ value: 'qty_available', label: '可用数', perm: 'inbound_buy:qty_available' },
{ value: 'unit_price', label: '不含税单价', perm: 'inbound_buy:unit_price' },
{ value: 'total_price', label: '不含税总价', perm: 'inbound_buy:total_price' },
{ value: 'tax_rate', label: '税率', perm: 'inbound_buy:tax_rate' },
{ value: 'currency', label: '币种', perm: 'inbound_buy:currency' },
{ value: 'exchange_rate', label: '汇率', perm: 'inbound_buy:exchange_rate' },
{ value: 'supplier_name', label: '供应商', perm: 'inbound_buy:supplier_name' },
{ value: 'purchaser', label: '采购人', perm: 'inbound_buy:purchaser' },
{ value: 'purchaser_email', label: '采购邮箱', perm: 'inbound_buy:purchaser_email' },
{ value: 'source_link', label: '原始链接', perm: 'inbound_buy:source_link' },
{ value: 'detail_link', label: '详情链接', perm: 'inbound_buy:detail_link' }
]
// 根据用户权限过滤
return allFields.filter(item => userStore.hasPermission(item.perm))
})
const operatorOptions = ref([
{ value: 'eq', label: '等于' },
{ value: 'ne', label: '不等于' },
{ value: 'contains', label: '包含' },
{ value: 'ge', label: '大于等于' },
{ value: 'le', label: '小于等于' }
])
// 基础列
const baseColumns = [
{prop: 'company_name', label: '所属公司'},
{prop: 'material_name', label: '名称'},
{prop: 'material_type', label: '类型'},
{prop: 'category', label: '类别'},
{prop: 'spec_model', label: '规格型号'},
{prop: 'unit', label: '单位'},
]
// 库存与商务列
const stockColumns = [
{prop: 'id', label: 'ID', minWidth: '60'},
{prop: 'base_id', label: 'BaseID', minWidth: '80'},
{prop: 'sku', label: 'SKU', minWidth: '120'},
{prop: 'inbound_date', label: '入库日期', minWidth: '120'},
{prop: 'barcode', label: '条码', minWidth: '120'},
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
{prop: 'status', label: '状态', minWidth: '100'},
{prop: 'inspection_status', label: '到检', minWidth: '100'},
{prop: 'qty_inbound', label: '入库量', minWidth: '100'},
{prop: 'qty_stock', label: '库存数', minWidth: '100'},
{prop: 'qty_available', label: '可用数', minWidth: '100'},
{prop: 'warehouse_loc', label: '库位', minWidth: '120'},
{prop: 'tax_rate', label: '税率', minWidth: '80'},
{prop: 'unit_price', label: '不含税单价', minWidth: '120'},
{prop: 'total_price', label: '不含税总价', minWidth: '120'},
{prop: 'currency', label: '币种', minWidth: '80'},
{prop: 'exchange_rate', label: '汇率', minWidth: '80'},
{prop: 'supplier_name', label: '供应商', minWidth: '150'},
{prop: 'purchaser', label: '采购人', minWidth: '100'},
{prop: 'purchaser_email', label: '邮箱', minWidth: '150'},
{prop: 'source_link', label: '采购链接', minWidth: '100'},
{prop: 'detail_link', label: '详情链接', minWidth: '100'},
{prop: 'arrival_photo', label: '到货图', minWidth: '100'},
{prop: 'inspection_report', label: '检测报告', minWidth: '100'}
]
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'inbound_buy:id',
base_id: 'inbound_buy:base_id',
company_name: 'inbound_buy:company_name',
material_name: 'inbound_buy:material_name',
material_type: 'inbound_buy:material_type',
category: 'inbound_buy:category',
spec_model: 'inbound_buy:spec_model',
unit: 'inbound_buy:unit',
sku: 'inbound_buy:sku',
inbound_date: 'inbound_buy:inbound_date',
barcode: 'inbound_buy:barcode',
sn_bn: 'inbound_buy:sn_bn',
status: 'inbound_buy:status',
inspection_status: 'inbound_buy:inspection_status',
qty_inbound: 'inbound_buy:qty_inbound',
qty_stock: 'inbound_buy:qty_stock',
qty_available: 'inbound_buy:qty_available',
warehouse_loc: 'inbound_buy:warehouse_loc',
tax_rate: 'inbound_buy:tax_rate',
unit_price: 'inbound_buy:unit_price',
total_price: 'inbound_buy:total_price',
currency: 'inbound_buy:currency',
exchange_rate: 'inbound_buy:exchange_rate',
supplier_name: 'inbound_buy:supplier_name',
purchaser: 'inbound_buy:purchaser',
purchaser_email: 'inbound_buy:purchaser_email',
source_link: 'inbound_buy:source_link',
detail_link: 'inbound_buy:detail_link',
arrival_photo: 'inbound_buy:arrival_photo',
inspection_report: 'inbound_buy:inspection_report'
}
// 初始化列显示状态(移除权限限制,添加 localStorage 支持)
const initColumnPermissions = () => {
// 生成存储键使用用户ID或用户名如果没有则使用浏览器唯一标识
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_buy_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 = defaultColumns
}
// 检查列权限(移除权限限制,始终返回 true
const hasColumnPermission = (prop: string) => {
return true
}
const allColumns = [...baseColumns, ...stockColumns]
const defaultColumns = [
'company_name',
'material_name', 'material_type', 'category', 'spec_model', 'unit',
'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status',
'tax_rate', 'unit_price', 'total_price',
'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report'
]
const visibleColumnProps = ref(defaultColumns)
// 监听列配置变化并保存到 localStorage
watch(visibleColumnProps, (newVal) => {
const userId = userStore.user?.id || userStore.username || 'anonymous'
const storageKey = `inbound_buy_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: '', category: '', unit: '', material_type: '',
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: undefined as number | undefined,
post_tax_unit_price: undefined as number | undefined,
total_price: undefined as number | undefined,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
arrival_photo: [] as string[], inspection_report: [] as string[],
print_copies: 1
})
// ------------------------------------
// 建议/Autocomplete 逻辑
// ------------------------------------
const fetchSupplierSuggestions = async (query: string, cb: any) => {
if (!form.base_id) { cb([]); return }
try {
const res: any = await getSupplierSuggestions({ base_id: form.base_id })
if (res.code === 200) {
const suppliers = res.data.map((name: string) => ({ value: name }))
const filtered = query ? suppliers.filter((item: any) => item.value.toLowerCase().includes(query.toLowerCase())) : suppliers
cb(filtered)
} else { cb([]) }
} catch (e) { cb([]) }
}
const querySearchSupplier = (qs: string, cb: any) => fetchSupplierSuggestions(qs, cb)
const handleSupplierSelect = (item: any) => { form.supplier_name = item.value }
const fetchUserSuggestions = async (query: string, cb: any) => {
try {
const res: any = await getUserSuggestions({ keyword: query })
if (res.code === 200) { cb(res.data) } else { cb([]) }
} catch (e) { cb([]) }
}
const querySearchPurchaser = (qs: string, cb: any) => fetchUserSuggestions(qs, cb)
const handlePurchaserSelect = (item: any) => { form.purchaser = item.value; if (item.email) form.purchaser_email = item.email }
const fetchLinkSuggestions = async (query: string, cb: any, type: 'original' | 'detail') => {
if (!form.base_id) { cb([]); return }
try {
const res: any = await getLinkSuggestions({ base_id: form.base_id, type })
if (res.code === 200) {
const links = res.data.map((link: string) => ({ value: link }))
const filtered = query ? links.filter((item:any) => item.value.toLowerCase().includes(query.toLowerCase())) : links
cb(filtered)
} else { cb([]) }
} catch(e) { cb([]) }
}
const querySearchLinks = (qs: string, cb: any, type: 'original' | 'detail') => fetchLinkSuggestions(qs, cb, type)
const fetchLocationSuggestions = async (query: string, cb: any) => {
if (!form.base_id) { cb([]); return }
try {
const res: any = await getLocationSuggestions({ base_id: form.base_id })
if (res.code === 200) {
const locs = res.data.map((loc: string) => ({ value: loc }))
const filtered = query ? locs.filter((item:any) => item.value.toLowerCase().includes(query.toLowerCase())) : locs
cb(filtered)
} else { cb([]) }
} catch(e) { cb([]) }
}
const querySearchLocation = (qs: string, cb: any) => fetchLocationSuggestions(qs, cb)
const currencyOptions = [{value: 'CNY', desc: '人民币'}, {value: 'USD', desc: '美元'}, {value: 'EUR', desc: '欧元'}]
const querySearchCurrency = (queryString: string, cb: any) => {
const filtered = queryString ? currencyOptions.filter(item => item.value.toLowerCase().includes(queryString.toLowerCase()) || item.desc.toLowerCase().includes(queryString.toLowerCase())) : currencyOptions
cb(filtered)
}
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)
if (res.data) {
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults
hasNextPage.value = res.has_next
}
} 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.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.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.category = item.category
form.unit = item.unit
form.material_type = item.type
checkHistoryAndSetMode(item.id)
}
}
// ------------------------------------
// 校验规则
// ------------------------------------
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
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
return false
})
if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
else callback()
}
const validateIdentity = (rule: any, value: any, callback: any) => {
if (entryMode.value === 'serial' && !form.serial_number && rule.field === 'serial_number') callback(new Error('SN必填'))
else if (entryMode.value === 'batch' && !form.batch_number && rule.field === 'batch_number') callback(new Error('批号必填'))
else callback()
}
const rules = {
base_id: [{required: true, message: '请选择物料', trigger: 'change'}],
in_quantity: [{required: true, message: '请输入数量', trigger: 'blur'}],
serial_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}],
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
}
const checkHistoryAndSetMode = async (baseId: number) => {
try {
const res: any = await getBuyList({page: 1, pageSize: 1000})
const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId)
if (historyItems.length > 0) {
modeLocked.value = true
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
if (latest.serial_number) {
entryMode.value = 'serial'
form.serial_number = ''
form.batch_number = ''
} else {
entryMode.value = 'batch'
form.serial_number = ''
form.batch_number = incrementBatchNumber(latest.batch_number || '000000')
}
} else {
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = '000001'
}
if (formRef.value) {
formRef.value.clearValidate('serial_number')
formRef.value.clearValidate('batch_number')
}
} catch (e) {
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = '000001'
}
}
const incrementBatchNumber = (batchStr: string) => {
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
return (parseInt(batchStr, 10) + 1).toString().padStart(6, '0')
}
const handleEntryModeChange = (val: string) => {
if (val === 'batch') {
form.serial_number = ''
form.batch_number = '000001'
if(formRef.value) formRef.value.clearValidate('serial_number')
} else {
form.batch_number = ''
if(formRef.value) formRef.value.clearValidate('batch_number')
}
}
// 价格联动计算 (精确到小数点后2位并支持空值)
const updatePrices = (source: string) => {
const taxMultiplier = 1 + (form.tax_rate || 0) / 100;
if (source === 'pre') {
if (form.unit_price !== undefined && form.unit_price !== null) {
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
} else {
form.post_tax_unit_price = undefined;
}
} else if (source === 'post') {
if (form.post_tax_unit_price !== undefined && form.post_tax_unit_price !== null) {
form.unit_price = Number((form.post_tax_unit_price / taxMultiplier).toFixed(2));
} else {
form.unit_price = undefined;
}
} else if (source === 'tax') {
if (form.unit_price !== undefined && form.unit_price !== null) {
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
}
}
if (form.in_quantity !== undefined && form.unit_price !== undefined && form.unit_price !== null) {
form.total_price = Number((form.in_quantity * form.unit_price).toFixed(2));
} else {
form.total_price = undefined;
}
}
watch(() => [form.in_quantity, form.unit_price], () => {
if (form.unit_price !== undefined && form.unit_price !== null) {
form.total_price = Number((form.in_quantity * form.unit_price).toFixed(2));
// 同时更新含税单价
const taxMultiplier = 1 + (form.tax_rate || 0) / 100;
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
} else {
form.total_price = undefined;
form.post_tax_unit_price = undefined;
}
})
const fetchData = async () => {
loading.value = true
try {
const params = {
...queryParams,
statuses: queryParams.statuses.join(','),
orderByColumn: queryParams.orderByColumn,
isAsc: queryParams.isAsc,
advancedFilters: JSON.stringify(queryParams.advancedFilters)
}
const res: any = await getBuyList(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
fetchData()
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = ''
visible.value = true
materialOptions.value = []
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
resetForm()
modeLocked.value = true
Object.assign(form, {
id: row.id, base_id: row.base_id,
company_name: row.company_name,
material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
unit_price: (row.unit_price !== null && row.unit_price !== undefined) ? Number(row.unit_price) : undefined,
total_price: (row.total_price !== null && row.total_price !== undefined) ? Number(row.total_price) : undefined,
tax_rate: Number(row.tax_rate),
currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || []
})
// 计算含税单价
if (form.unit_price !== undefined && form.unit_price !== null) {
const taxMultiplier = 1 + (form.tax_rate || 0) / 100;
form.post_tax_unit_price = Number((form.unit_price * taxMultiplier).toFixed(2));
}
arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.inspection_report || []
const reportImgs = reports.filter(r => !isExternalLink(r))
const reportLinks = reports.filter(r => isExternalLink(r))
reportFileList.value = reportImgs.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
inspection_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name }]
visible.value = true
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitting.value = true
const finalReportList = [...form.inspection_report]
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
const payload = {
...form,
inspection_report: onlyImages,
in_quantity: Number(form.in_quantity || 0),
unit_price: Number(form.unit_price || 0),
post_tax_unit_price: Number(form.post_tax_unit_price || 0)
}
try {
if (dialogStatus.value === 'create') {
const res: any = await createBuyInbound(payload)
ElMessage.success('入库成功')
if (res.data) {
ElMessage.info('发送打印指令...')
try {
// [已修改] 传递用户选择的打印份数
await executePrint({ ...res.data, copies: form.print_copies });
ElMessage.success(`打印指令已发送 (x${form.print_copies})`)
}
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
}
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
await fetchData()
visible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
}
})
}
const getImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('https') || url.startsWith('blob:')) {
return url
}
const apiBase = import.meta.env.VITE_APP_BASE_API || ''
const baseUrl = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase
const path = url.startsWith('/') ? url : '/' + url
return baseUrl + path
}
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
const hasExternalLink = (list: string[]) => { return !list ? false : 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: 'arrival_photo' | 'inspection_report') => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
form[targetField].push(newUrl)
const fullUrl = getImageUrl(newUrl)
const fileObj = { name: file.name, url: fullUrl, status: 'success', uid: file.uid }
if (targetField === 'arrival_photo') {
const idx = arrivalFileList.value.findIndex(f => f.uid === file.uid)
if (idx > -1) arrivalFileList.value[idx] = fileObj
else arrivalFileList.value.push(fileObj)
} else {
const idx = reportFileList.value.findIndex(f => f.uid === file.uid)
if (idx > -1) reportFileList.value[idx] = fileObj
else reportFileList.value.push(fileObj)
}
ElMessage.success('上传成功')
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败')
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误')
onError(e)
}
}
const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' | 'inspection_report') => {
try {
const filename = uploadFile.url.split('/').pop()
const urlToRemove = form[targetField].find(u => u.endsWith(filename)) || uploadFile.url
form[targetField] = form[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) {
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: 'arrival_photo' | 'inspection_report') => {
currentCameraField.value = field;
cameraDialogVisible.value = true;
}
const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) {
return
}
const loadingInstance = ElLoading.service({
lock: true,
text: '照片上传中,请稍候...',
background: 'rgba(0, 0, 0, 0.7)',
})
try {
const formData = new FormData()
formData.append('file', file)
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
const field = currentCameraField.value
form[field].push(newUrl)
const fileObj = { name: file.name, url: getImageUrl(newUrl) }
if (field === 'arrival_photo') {
arrivalFileList.value.push(fileObj)
} else if (field === 'inspection_report') {
reportFileList.value.push(fileObj)
}
ElMessage.success('拍照上传成功')
cameraDialogVisible.value = false
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch (e: any) {
ElMessage.error('网络错误,上传失败')
} finally {
loadingInstance.close()
}
}
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 isColumnSortable = (prop: string) => {
const sortableColumns = ['company_name', 'material_name', 'material_type', 'category', 'spec_model', 'unit', 'sku', 'barcode', 'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status', 'qty_inbound', 'qty_stock', 'qty_available', 'warehouse_loc', 'unit_price', 'total_price', 'tax_rate', 'currency', 'exchange_rate', 'supplier_name', 'purchaser', 'purchaser_email', 'source_link', 'detail_link']
return sortableColumns.includes(prop)
}
const handleSortChange = ({ column, prop, order }: any) => {
if (prop && isColumnSortable(prop)) {
queryParams.orderByColumn = prop
queryParams.isAsc = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined
} else {
queryParams.orderByColumn = ''
queryParams.isAsc = undefined
}
queryParams.page = 1
fetchData()
}
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
// ------------------------------------
// 打印逻辑
// ------------------------------------
const handlePrint = async (row: any) => {
printVisible.value = true;
printLoading.value = true;
previewUrl.value = '';
printCopies.value = 1;
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, batch_number: row.batch_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('指令已发送');
printVisible.value = false
} catch (e: any) {
ElMessage.error(e.msg || '打印失败')
} finally {
printing.value = false
}
}
const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
Object.assign(form, {
id: undefined, base_id: undefined,
company_name: '',
material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: undefined, post_tax_unit_price: undefined, total_price: undefined,
tax_rate: 0,
currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [],
print_copies: 1
})
}
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' }
// 列表金额显示增加千分位处理并保留2位小数
const formatMoney = (val: any, currency = '¥') => {
const num = Number(val);
if (isNaN(num)) return '-';
const parts = num.toFixed(2).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${currency} ${parts.join('.')}`;
}
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData()
fetchOptions()
loadWarehouseTree()
})
</script>
<style scoped>
.buy-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
padding: 16px 20px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
margin-bottom: 20px;
}
.search-form-area {
display: flex;
align-items: center;
gap: 12px;
}
.filter-item-input {
/* 输入框样式 */
}
.filter-item-select {
/* 确保下拉框高度和输入框一致 */
}
.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;
}
.right-actions {
display: flex;
align-items: center;
gap: 12px;
}
.add-btn {
background-color: #409EFF;
border-color: #409EFF;
padding: 8px 18px;
}
.circle-btn {
color: #606266;
border-color: #dcdfe6;
}
.modern-table { border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; font-weight: 600; height: 50px; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; background: #ecf5ff; padding: 0 4px; border-radius: 4px; margin-right: 4px; font-size: 12px; }
.tag-bn { color: #67C23A; font-weight: bold; font-family: monospace; background: #f0f9eb; padding: 0 4px; border-radius: 4px; margin-right: 4px; font-size: 12px; }
.id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; }
.money-text { font-family: 'Consolas', monospace; color: #303133; }
.stock-num { font-weight: bold; color: #333; font-size: 15px; }
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.sum-tag { margin-left: 4px; transform: scale(0.9); }
:deep(.el-dialog__body) { padding: 0; overflow: hidden; }
/* [已修改] 增加 min-height 确保弹窗即使内容少时也保持美观高度,防止被下拉框“压垮” */
.dialog-scroll-container { padding: 15px 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; min-height: 450px; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 15px; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px; color: #303133; display: flex; align-items: center; }
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
.card-content { padding: 15px 20px; }
.basic-card { border-left: 4px solid #409EFF; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
/* 只读输入框样式:纯文本风格 */
.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;
}
.inbound-card { border-left: 4px solid #67C23A; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 12px; margin-bottom: 15px; }
.custom-radio-group { margin-bottom: 10px; }
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
.prefix-tag { font-weight: bold; font-size: 12px; padding: 0 5px; border-radius: 4px; }
.prefix-tag.bn { color: #67C23A; background: #f0f9eb; }
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; }
.divider-text { display: flex; align-items: center; text-align: center; margin: 20px 0 15px; color: #909399; font-size: 13px; font-weight: 500; }
.divider-text::before, .divider-text::after { content: ''; flex: 1; border-bottom: 1px solid #ebeef5; }
.divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 15px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 15px 20px; background: #fff; border-top: 1px solid #ebeef5; }
/* [重点优化] 下拉框选项样式 */
.option-item {
display: flex;
align-items: center;
padding: 8px 0;
width: 100%;
}
/* 名称区域:占据剩余空间,但必须有 min-width: 0 以触发 ellipsis */
.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;
}
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
.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; }
.clickable-text:hover { color: #66b1ff; }
.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; }
/* 自定义千分位无箭头输入框样式,用于强迫症优化显示 */
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
</style>
<style>
/* 针对开启 teleport 后,挂载在 body 下的 dropdown */
.long-dropdown {
width: 580px !important; /* 固定宽度,比输入框稍宽以展示更多信息 */
}
.long-dropdown .el-select-dropdown__wrap {
max-height: 320px !important; /* 限制高度,避免遮挡整个弹窗 */
}
/* [新增] 修复清除按钮被内容遮挡的问题 (Element Plus 偶发 Bug) */
.long-dropdown .el-input__suffix {
z-index: 10;
}
</style>