Files
KCGL/inventory-web/src/views/stock/inbound/buy.vue
dxc afcf90a859 feat: enforce field-level permissions for buy and service modules
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
2026-02-27 15:03:44 +08:00

1503 lines
64 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">
<div class="search-form-area">
<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: 240px;"
>
<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>
</div>
<div class="right-actions">
<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" :disabled="!hasColumnPermission(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" :disabled="!hasColumnPermission(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"
>
<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
>
<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"
>
<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">删除</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="1000px"
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="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" clearable/></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="库位" prop="warehouse_location">
<el-autocomplete
v-model="form.warehouse_location"
:fetch-suggestions="querySearchLocation"
placeholder="例如: A-01-02"
style="width: 100%"
clearable
:trigger-on-focus="true"
>
<template #default="{ item }"><div style="font-weight: 500">{{ item.value }}</div></template>
</el-autocomplete>
</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"/>
</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="6">
<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="6"><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="6">
<el-form-item label="税率">
<el-select v-model="form.tax_rate" style="width:100%">
<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-col :span="6"><el-form-item label="不含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<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} 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 WebRtcCamera from '@/components/Camera/WebRtcCamera.vue'
import { useUserStore } from '@/stores/user'
// ------------------------------------
// 自定义指令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: 100,
keyword: '',
category: '',
material_type: '',
company: '',
statuses: ['在库', '借库']
})
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 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'
}
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return
}
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏
// 遍历 allColumns将没有权限的列从 visibleColumnProps 中移除
const allowedColumns = allColumns.filter(col => {
const code = permissionMap[col.prop]
if (code) {
return userStore.hasPermission(code)
}
// 如果没有映射,默认隐藏
return false
}).map(col => col.prop)
// 更新 visibleColumnProps只保留有权限的列
// 同时保持用户之前已经选择的有权限的列
const currentVisible = visibleColumnProps.value.filter(prop => allowedColumns.includes(prop))
// 如果当前没有可见列,则使用 allowedColumns 作为默认
if (currentVisible.length === 0) {
visibleColumnProps.value = allowedColumns
} else {
visibleColumnProps.value = currentVisible
}
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
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)
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: 0, total_price: 0,
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')
}
}
watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) })
const fetchData = async () => {
loading.value = true
try {
const params = {
...queryParams,
statuses: queryParams.statuses.join(',')
}
const res: any = await getBuyList(params)
tableData.value = res.data.items || []
total.value = res.data.total || 0
} finally { loading.value = false }
}
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 resetQuery = () => {
queryParams.keyword = ''
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: Number(row.unit_price), total_price: Number(row.total_price),
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 || []
})
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), unit_price: Number(form.unit_price) }
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 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: 0, total_price: 0,
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' }
const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` }
onMounted(() => {
// 先根据权限初始化列显示状态
initColumnPermissions()
fetchData()
fetchOptions()
})
</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; }
</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>