Files
KCGL/inventory-web/src/views/material/list.vue

1831 lines
69 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="app-container">
<el-card shadow="never">
<div class="filter-wrapper">
<div class="filter-container">
<el-input
v-model="queryParams.keyword"
placeholder="请输入搜索关键字"
style="width: 320px; margin-right: 10px;"
clearable
@input="handleInputSearch"
>
<template #prepend>
<el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery">
<el-option label="全部" value="all" />
<el-option label="名称" value="name" />
<el-option label="俗名" value="common_name" />
<el-option label="规格" value="spec" />
</el-select>
</template>
</el-input>
<el-select
v-model="queryParams.company"
placeholder="所属公司"
clearable
filterable
default-first-option
style="width: 120px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.category"
placeholder="类别"
clearable
filterable
allow-create
default-first-option
style="width: 240px; margin-right: 10px;"
@change="handleQuery"
popper-class="long-dropdown"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.type"
placeholder="类型"
clearable
filterable
allow-create
default-first-option
style="width: 140px; margin-right: 10px;"
@change="handleQuery"
popper-class="long-dropdown"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.isEnabled"
placeholder="状态"
clearable
style="width: 100px; margin-right: 10px;"
@change="handleQuery"
>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
<el-select
v-model="queryParams.has_stock"
placeholder="库存状态"
clearable
style="width: 120px; margin-right: 10px;"
@change="handleQuery"
>
<el-option label="全部" value="" />
<el-option label="仅看有库存" value="true" />
</el-select>
<el-button type="primary" plain @click="handleQuery">搜索</el-button>
<el-button plain @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" :teleported="false">
<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" :teleported="false">
<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-toolbar">
<el-button type="success" plain @click="handleExport" :loading="exportLoading" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Download /></el-icon>导出库存统计
</el-button>
<!-- 批量操作按钮 (需要相应权限) -->
<template v-if="!isBatchMode">
<el-button
v-if="userStore.hasPermission('material_list:edit_warning')"
type="warning"
plain
@click="enterBatchMode('warning')"
style="margin-right: 10px"
>
<el-icon style="margin-right: 5px"><Bell /></el-icon>批量设置预警
</el-button>
<el-button
v-if="userStore.hasPermission('material_list:operation')"
type="danger"
plain
@click="enterBatchMode('inspection')"
style="margin-right: 10px"
>
<el-icon style="margin-right: 5px"><CircleCheck /></el-icon>批量质检设置
</el-button>
</template>
<template v-else>
<el-button @click="cancelBatchMode">取消选择</el-button>
<el-button type="primary" @click="confirmBatchSelection">确认勾选</el-button>
</template>
<el-button v-if="userStore.hasPermission('material_list:operation')" type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button>
<el-tooltip content="刷新" placement="top">
<el-button circle :icon="Refresh" @click="getList" />
</el-tooltip>
<el-dropdown trigger="click" @command="handleSizeChange">
<el-button circle :icon="Rank" style="margin-left: 8px" title="表格密度" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="large">宽松 (默认)</el-dropdown-item>
<el-dropdown-item command="default">中等</el-dropdown-item>
<el-dropdown-item command="small">紧凑</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-popover placement="bottom" :width="200" trigger="click">
<template #reference>
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template>
<div class="column-setting-list">
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置
</div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.category.visible" label="类别" />
<el-checkbox v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-model="columns.available.visible" label="可用数" />
<el-checkbox v-model="columns.files.visible" label="资料" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
</div>
</el-popover>
</div>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="tableData"
border
stripe
row-key="id"
:size="tableSize"
:row-class-name="tableRowClassName"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
style="width: 100%; margin-top: 15px"
>
<el-table-column v-if="isBatchMode" type="selection" width="55" :reserve-selection="true" />
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom">
<template #default="scope">
<span>{{ scope.row.companyName || '-' }}</span>
</template>
</el-table-column>
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip sortable="custom">
<template #default="scope">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="140" show-overflow-tooltip sortable="custom">
<template #default="scope">{{ scope.row.category || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip sortable="custom">
<template #default="scope">{{ scope.row.type || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip sortable="custom" />
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" sortable="custom" />
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center" sortable="custom">
<template #default="{ row }">
<span>{{ row.inventoryCount }}</span>
</template>
</el-table-column>
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center" sortable="custom">
<template #default="{ row }">
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : 'inherit' }">{{ row.availableCount }}</span>
</template>
</el-table-column>
<el-table-column v-if="columns.files.visible" label="资料" min-width="140" align="center">
<template #default="{ row }">
<div style="display: flex; gap: 8px; justify-content: center;">
<div v-if="getImagesOnly(row.generalImage).length > 0" class="file-preview-cell">
<el-image
style="width: 32px; height: 32px; border-radius: 4px;"
:src="getImageUrl(getImagesOnly(row.generalImage)[0])"
:preview-src-list="getImagesOnly(row.generalImage).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
/>
<span v-if="getImagesOnly(row.generalImage).length > 1" class="more-badge">+{{getImagesOnly(row.generalImage).length}}</span>
</div>
<el-popover v-if="row.generalManual && row.generalManual.length > 0" placement="top" trigger="hover" width="260">
<template #reference>
<el-button link type="primary" :icon="row.generalManual.some(l => !isExternalLink(l) && !isImageFile(l)) ? Files : Document" />
</template>
<div style="display: flex; flex-direction: column; gap: 5px;">
<!-- 图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l))" :key="'img-' + idx">
<el-image
style="width: 80px; height: 80px; cursor: pointer;"
:src="getImageUrl(link)"
:preview-src-list="row.generalManual.filter(l => !isExternalLink(l) && isImageFile(l)).map(u => getImageUrl(u))"
fit="cover"
preview-teleported
/>
<span style="font-size: 12px; color: #999;">图片 {{idx+1}}</span>
</div>
<!-- 非图片文件 -->
<div v-for="(link, idx) in row.generalManual.filter(l => !isExternalLink(l) && !isImageFile(l))" :key="'file-' + idx">
<el-link @click.prevent="handleDownloadConfirm(link)" type="info" :underline="false">
<el-icon v-if="isCompressedFile(link)"><Zipper /></el-icon>
<el-icon v-else><Files /></el-icon>
{{ link.split('/').pop() }}
</el-link>
</div>
</div>
</el-popover>
</div>
</template>
</el-table-column>
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.isEnabled"
:active-value="true"
:inactive-value="false"
:loading="scope.row.statusLoading"
:disabled="!userStore.hasPermission('material_list:operation')"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column v-if="columns.isInspectionRequired.visible" prop="isInspectionRequired" label="强制质检" min-width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.isInspectionRequired ? 'danger' : 'info'" size="small">
{{ scope.row.isInspectionRequired ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:view_warning')" label="预警状态" width="120" align="center">
<template #default="{ row }">
<template v-if="row.warningStatus === 2">
<el-tag type="danger" size="small">红色预警</el-tag>
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningRed }}</div>
</template>
<template v-else-if="row.warningStatus === 1">
<el-tag type="warning" size="small">黄色预警</el-tag>
<div style="font-size: 11px; color: #999;">阈值: {{ row.warningYellow }}</div>
</template>
<template v-else-if="row.warningEnabled">
<el-tag type="success" size="small">已配置</el-tag>
</template>
<span v-else style="color: #c0c4cc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('material_list:operation')" label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button v-if="userStore.hasPermission('material_list:edit_warning')" link type="warning" size="small" @click="handleSetSingleWarning(scope.row)">设置预警</el-button>
<template v-if="userStore.hasPermission('material_list:edit_warning') && scope.row.warningStatus > 0">
<el-button v-if="scope.row.warningOrdered" disabled size="small" type="info">采购在途</el-button>
<el-button v-else link type="success" size="small" @click="handleMarkOrdered(scope.row)">标记已采购</el-button>
</template>
<el-button v-if="userStore.hasPermission('material_list:operation')" link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handlePageSizeChange"
@current-change="handlePageCurrentChange"
/>
</div>
<el-dialog
v-model="dialog.visible"
width="1200px"
append-to-body
destroy-on-close
@close="cancel"
:close-on-click-modal="!isUploading"
:close-on-press-escape="!isUploading"
:show-close="!isUploading"
>
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;">
<span style="font-size: 18px; font-weight: 500;">{{ dialog.title }}</span>
<el-link
v-if="form.id"
type="success"
:underline="false"
style="font-size: 14px;"
@click="createBomForMaterial"
>
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-row>
<el-col :span="12">
<el-form-item label="名称" prop="name" v-if="hasFieldPermission('name')">
<el-input v-model="form.name" placeholder="内部名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="俗名" prop="commonName" v-if="hasFieldPermission('commonName')">
<el-input v-model="form.commonName" placeholder="标准名称" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="所属公司" prop="companyName" v-if="hasFieldPermission('companyName')">
<el-autocomplete
v-model="form.companyName"
:fetch-suggestions="querySearchCompany"
placeholder="请输入公司名称"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
<div style="display: flex; width: 100%; align-items: center;">
<el-cascader
ref="categoryCascaderRef"
v-model="tempCategoryPrefix"
:options="categoryTreeOptions"
:props="{ expandTrigger: 'hover', checkStrictly: true, emitPath: true }"
placeholder="选择前缀层级"
filterable
clearable
style="width: 50%;"
@change="onCategoryChange"
/>
<div style="padding: 0 8px; font-weight: bold; color: #909399;">/</div>
<el-input
v-model="tempCategorySuffix"
placeholder="填写具体名称"
clearable
style="width: 50%;"
/>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')">
<el-input v-model="form.unit" placeholder=": , , " />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="可见等级" prop="visibilityLevel">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
<div class="upload-container">
<el-upload
v-model:file-list="fileListImage"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'generalImage')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'generalImage')"
:before-upload="beforeAvatarUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('generalImage')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div>
</div>
<el-input
v-model="imageExternalUrl"
placeholder="如有外部图片链接请在此输入"
style="margin-top: 8px;"
clearable
>
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
<div class="upload-container">
<el-upload
v-model:file-list="fileListManual"
action="#"
list-type="picture-card"
multiple
:http-request="(opts) => customUpload(opts, 'generalManual')"
:on-preview="handlePreviewPicture"
:on-remove="(file) => handleRemoveImage(file, 'generalManual')"
:before-upload="beforeAvatarUpload"
>
<template #default>
<div v-if="!fileListManual.length" class="upload-add-trigger">
<el-icon><Plus /></el-icon>
</div>
</template>
<template #file="{ file }">
<div class="upload-file-item">
<template v-if="isImageFile(file.url)">
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
</template>
<template v-else>
<div class="file-thumbnail">
<el-icon size="28"><Document /></el-icon>
<span class="file-name">{{ truncateFileName(file.name) }}</span>
</div>
</template>
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePreviewPicture(file)">
<el-icon><ZoomIn /></el-icon>
</span>
<span class="el-upload-list__item-delete" @click.stop.prevent="() => handleRemoveImage(file, 'generalManual')">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<div class="camera-card" @click="triggerCamera('generalManual')">
<el-icon><Camera /></el-icon><span class="text">拍照</span>
</div>
</div>
<el-input
v-model="manualExternalUrl"
placeholder="如有外部说明书链接请在此输入"
style="margin-top: 8px;"
clearable
>
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
<el-radio-group v-model="form.isEnabled">
<el-radio :value="true">启用</el-radio>
<el-radio :value="false">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel" :disabled="isUploading">取 消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading || isUploading">确 定</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="warningDialog.visible" :title="warningDialog.title" width="500px" append-to-body destroy-on-close>
<el-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
<el-alert
v-if="warningDialog.selectedCount > 1"
:title="`正在批量设置 ${warningDialog.selectedCount} 条物料的预警`"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-form-item label="启用预警" prop="isEnabled">
<el-switch v-model="warningForm.isEnabled" />
</el-form-item>
<el-form-item label="红色阈值" prop="redThreshold" v-if="warningForm.isEnabled">
<el-input-number v-model="warningForm.redThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为红色预警" style="width: 100%" />
<div class="form-tip">库存数量 ≤ 此值时显示红色预警</div>
</el-form-item>
<el-form-item label="红色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.redEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
<el-form-item label="黄色阈值" prop="yellowThreshold" v-if="warningForm.isEnabled">
<el-input-number v-model="warningForm.yellowThreshold" :min="0" :precision="0" step="1" placeholder="库存此值为黄色预警" style="width: 100%" />
<div class="form-tip">红色阈值 &lt; 库存 ≤ 此值时显示黄色预警</div>
</el-form-item>
<el-form-item label="黄色预警邮箱" v-if="warningForm.isEnabled">
<el-input v-model="warningForm.yellowEmails" placeholder="逗号分隔多个邮箱" clearable />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="warningDialog.visible = false">取 消</el-button>
<el-button type="primary" @click="submitWarning" :loading="warningLoading">确 定</el-button>
</div>
</template>
</el-dialog>
<!-- 批量质检设置弹窗 -->
<el-dialog v-model="inspectionDialog.visible" title="批量质检设置" width="500px" append-to-body destroy-on-close>
<el-alert
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
type="info"
:closable="false"
style="margin-bottom: 20px"
/>
<el-form label-position="top">
<el-form-item label="是否强制要求入库上传检测报告">
<el-switch
v-model="inspectionForm.isInspectionRequired"
active-text=" (强制管控)"
inactive-text=" (免检入库)"
/>
<div style="color: #909399; font-size: 12px; width: 100%; margin-top: 8px; line-height: 1.5;">
开启后,这些物料在采购入库时必须上传检测报告文件或填写外部报告链接,否则将被拦截无法入库。
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="inspectionDialog.visible = false">取 消</el-button>
<el-button type="primary" @click="submitBatchInspection" :loading="inspectionLoading">确 定</el-button>
</div>
</template>
</el-dialog>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
import {
listMaterialBase,
addMaterialBase,
updateMaterialBase,
delMaterialBase,
getMaterialBaseOptions,
exportAssetStatistics,
batchSetWarning,
batchSetInspection,
markWarningOrdered
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
const userStore = useUserStore();
// --- 类型定义 ---
interface MaterialBaseVO {
id: number;
companyName: string;
name: string;
commonName?: string;
category: string;
type: string;
spec: string;
unit: string;
visibilityLevel: number;
generalManual: string[];
generalImage: string[];
isEnabled: boolean; // 已彻底修改为布尔值
statusLoading?: boolean;
inventoryCount?: number;
availableCount?: number;
warningStatus?: number;
warningOrdered?: boolean;
warningRedEmails?: string;
warningYellowEmails?: string;
}
interface QueryParams {
pageNum: number;
pageSize: number;
keyword: string;
searchField: string;
category: string;
type: string;
company: string;
isEnabled?: boolean;
orderByColumn: string;
isAsc: string | undefined;
advancedFilters?: any[];
has_stock?: string;
enableWarningSort?: boolean;
}
interface CascaderOption {
value: string;
label: string;
children?: CascaderOption[];
}
// --- 响应式数据 ---
const loading = ref(false);
const exportLoading = ref(false); // 导出加载状态
const total = ref(0);
const tableData = ref<MaterialBaseVO[]>([]);
const tableRef = ref<InstanceType<typeof ElTable>>();
const submitLoading = ref(false);
// 上传锁定状态
const isUploading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
const advancedFilterVisible = ref(false);
const advancedConditions = ref([{ field: '', operator: '', value: '' }]);
const fieldOptions = computed(() => {
const allFields = [
{ value: 'companyName', label: '所属公司', perm: 'material_list:companyName' },
{ value: 'name', label: '名称', perm: 'material_list:name' },
{ value: 'commonName', label: '俗名', perm: 'material_list:commonName' },
{ value: 'category', label: '类别', perm: 'material_list:category' },
{ value: 'type', label: '类型', perm: 'material_list:type' },
{ value: 'spec', label: '规格型号', perm: 'material_list:spec' },
{ value: 'unit', label: '单位', perm: 'material_list:unit' },
{ value: 'inventoryCount', label: '库存数', perm: 'material_list:inventoryCount' },
{ value: 'availableCount', label: '可用数', perm: 'material_list:availableCount' }
];
// 根据用户权限过滤
return allFields.filter(item => userStore.hasPermission(item.perm));
});
const operatorOptions = ref([
{ value: 'eq', label: '等于' },
{ value: 'ne', label: '不等于' },
{ value: 'contains', label: '包含' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ge', label: '大于等于' },
{ value: 'le', label: '小于等于' }
]);
// 文件上传相关
const fileListImage = ref<any[]>([]);
const fileListManual = ref<any[]>([]);
const imageExternalUrl = ref('');
const manualExternalUrl = ref('');
const dialogVisibleImage = ref(false);
const dialogImageUrl = ref('');
const cameraDialogVisible = ref(false);
const cameraRef = ref<InstanceType<typeof WebRtcCamera> | null>(null);
const currentCameraField = ref<'generalImage' | 'generalManual'>('generalImage');
// 脏检查 - 记录编辑前的原始数据
const originalForm = ref<any>(null);
// 复选框选中数据
const selectedItems = ref<MaterialBaseVO[]>([]);
const handleSelectionChange = (selection: MaterialBaseVO[]) => {
selectedItems.value = selection;
};
// 批量操作模式
const isBatchMode = ref(false);
const batchActionType = ref(''); // 'warning' | 'inspection'
const enterBatchMode = (actionType: string) => {
batchActionType.value = actionType;
selectedItems.value = [];
isBatchMode.value = true;
tableRef.value?.clearSelection();
};
const cancelBatchMode = () => {
isBatchMode.value = false;
batchActionType.value = '';
tableRef.value?.clearSelection();
selectedItems.value = [];
};
const confirmBatchSelection = () => {
const selected = tableRef.value?.getSelectionRows() || [];
if (selected.length === 0) {
return ElMessage.warning('请先勾选需要操作的物料');
}
// 根据操作类型路由到对应的业务弹窗
if (batchActionType.value === 'inspection') {
inspectionDialog.selectedIds = selected.map((row: any) => row.id);
inspectionDialog.selectedCount = selected.length;
inspectionForm.isInspectionRequired = false;
inspectionDialog.visible = true;
} else if (batchActionType.value === 'warning') {
// 打开预警设置弹窗
warningDialog.selectedIds = selected.map((row: any) => row.id);
warningDialog.selectedCount = selected.length;
warningForm.isEnabled = false;
warningForm.redThreshold = undefined;
warningForm.yellowThreshold = undefined;
warningDialog.title = '批量设置预警';
warningDialog.visible = true;
}
// 关闭批量模式
isBatchMode.value = false;
batchActionType.value = '';
};
// 兼容旧的 exitBatchMode
const exitBatchMode = () => {
cancelBatchMode();
};
// 预警设置相关
const warningDialog = reactive({
visible: false,
title: '设置预警',
selectedCount: 0,
selectedIds: [] as number[]
});
const warningFormRef = ref<FormInstance>();
const warningLoading = ref(false);
const warningForm = reactive({
isEnabled: false,
redThreshold: undefined as number | undefined,
yellowThreshold: undefined as number | undefined,
redEmails: '',
yellowEmails: ''
});
const warningRules = {
yellowThreshold: [
{
validator: (rule: any, value: any, callback: any) => {
if (warningForm.isEnabled && warningForm.redThreshold !== undefined && value !== undefined) {
if (value <= warningForm.redThreshold) {
callback(new Error('黄色阈值必须大于红色阈值'));
} else {
callback();
}
} else {
callback();
}
},
trigger: 'blur'
}
]
};
// 批量质检设置相关
const inspectionDialog = reactive({
visible: false,
selectedCount: 0,
selectedIds: [] as number[]
});
const inspectionLoading = ref(false);
const inspectionForm = reactive({
isInspectionRequired: false
});
const columns = reactive({
id: { visible: false },
companyName: { visible: true },
name: { visible: true },
commonName: { visible: true },
category: { visible: true },
type: { visible: true },
spec: { visible: true },
unit: { visible: true },
inventory: { visible: true },
available: { visible: true },
files: { visible: true },
isEnabled: { visible: true },
isInspectionRequired: { visible: true }
});
// 列与权限Code的映射关系数据库中的code
const permissionMap: Record<string, string> = {
id: 'material_list:id',
companyName: 'material_list:companyName',
name: 'material_list:name',
commonName: 'material_list:commonName',
category: 'material_list:category',
type: 'material_list:type',
spec: 'material_list:spec',
unit: 'material_list:unit',
inventory: 'material_list:inventoryCount',
available: 'material_list:availableCount',
files: 'material_list:files',
isEnabled: 'material_list:isEnabled',
isInspectionRequired: 'material_list:operation'
};
// 根据用户权限初始化列显示状态
const initColumnPermissions = () => {
// 超级管理员跳过权限检查,显示所有列
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return;
}
// 普通用户:严格执行列级权限控制,没有权限的列必须隐藏
Object.keys(columns).forEach(key => {
const code = permissionMap[key];
if (code) {
// 如果不具备该权限,必须设为 false
columns[key].visible = !!userStore.hasPermission(code);
}
});
};
// 检查字段权限(用于表单)
const hasFieldPermission = (field: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true;
}
const code = permissionMap[field];
// 如果permissionMap中没有该字段默认允许
if (!code) {
return true;
}
return userStore.hasPermission(code);
};
const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
// 类别级联选择器的 ref
const categoryCascaderRef = ref<any>(null);
// 选中类别后自动收起下拉面板
const onCategoryChange = () => {
if (categoryCascaderRef.value) {
categoryCascaderRef.value.togglePopperVisible(false);
}
};
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
const queryParams = reactive<QueryParams>({
pageNum: 1,
pageSize: 10,
keyword: '',
searchField: 'all',
category: '',
type: '',
company: '',
isEnabled: undefined,
orderByColumn: '',
isAsc: undefined,
advancedFilters: [],
has_stock: ''
});
// --- 弹窗与表单相关 ---
const dialog = reactive({
visible: false,
title: ''
});
const formRef = ref<FormInstance>();
const initForm = {
id: undefined,
companyName: '',
name: '',
commonName: '',
category: '',
type: '',
spec: '',
unit: '',
visibilityLevel: 0,
generalManual: [] as string[],
generalImage: [] as string[],
isEnabled: true // 已修改为默认 true
};
const form = ref({...initForm});
const validateCategoryLevel = (rule: any, value: any, callback: any) => {
const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim();
if (!prefixStr && !suffixStr) {
callback(new Error('请填写或选择类别'));
} else {
// 只要有前缀或后缀就直接放行不再限制必须是4层
callback();
}
};
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
companyName: [{ required: true, message: '请输入公司名称', trigger: 'change' }],
category: [{ required: true, validator: validateCategoryLevel, trigger: 'change' }],
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
});
// --- 业务逻辑方法 ---
const buildCategoryTree = (categories: string[]): CascaderOption[] => {
const root: CascaderOption[] = [];
categories.forEach(cat => {
if (!cat) return;
const parts = cat.split('/');
let currentLevel = root;
parts.forEach((part, index) => {
let existingNode = currentLevel.find(n => n.value === part);
if (!existingNode) {
existingNode = { value: part, label: part };
currentLevel.push(existingNode);
}
if (index < parts.length - 1) {
if (!existingNode.children) {
existingNode.children = [];
}
currentLevel = existingNode.children;
}
});
});
return root;
};
const getOptionsList = () => {
getMaterialBaseOptions().then((res: any) => {
if (res.code === 200) {
categoryOptions.value = res.data.categories || [];
typeOptions.value = res.data.types || [];
companyOptions.value = res.data.companies || [];
categoryTreeOptions.value = buildCategoryTree(categoryOptions.value);
}
}).catch(err => {
console.error("获取筛选项失败", err);
});
};
const querySearchCompany = (queryString: string, cb: any) => {
const results = queryString
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: companyOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const querySearchType = (queryString: string, cb: any) => {
const results = queryString
? typeOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: typeOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const getList = () => {
loading.value = true;
// 仅当用户没有进行手动表头排序时才开启预警排序
queryParams.enableWarningSort = userStore.hasPermission('material_list:view_warning') && !queryParams.orderByColumn;
// Stringify advancedFilters to JSON string as backend expects
const params = {
...queryParams,
advancedFilters: JSON.stringify(queryParams.advancedFilters || [])
};
listMaterialBase(params)
.then((response: any) => {
if (response && response.data) {
tableData.value = response.data.items;
total.value = response.data.total;
} else {
tableData.value = [];
total.value = 0;
}
})
.catch((err) => {
console.error(err);
tableData.value = [];
})
.finally(() => {
loading.value = false;
});
};
// [修改] 导出处理函数:修正文件名格式
const handleExport = () => {
exportLoading.value = true;
const params = {
keyword: queryParams.keyword,
company: queryParams.company,
category: queryParams.category,
type: queryParams.type,
isEnabled: queryParams.isEnabled
};
exportAssetStatistics(params)
.then((response: any) => {
const blob = new Blob([response], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 构造文件名库存统计_YYYYMMDD_HHMMSS
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
const second = String(now.getSeconds()).padStart(2, '0');
const filename = `库存统计_${year}${month}${day}_${hour}${minute}${second}.xlsx`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
})
.catch((err) => {
console.error("导出失败", err);
ElMessage.error('导出失败');
})
.finally(() => {
exportLoading.value = false;
});
};
let searchTimer: any = null;
const handleInputSearch = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
queryParams.pageNum = 1;
getList();
}, 500);
};
const handleSortChange = ({ column, prop, order }: any) => {
const sortableColumns = ['inventoryCount', 'availableCount', 'companyName', 'name', 'commonName', 'category', 'type', 'spec', 'unit'];
if (prop && sortableColumns.includes(prop)) {
queryParams.orderByColumn = prop;
queryParams.isAsc = order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : undefined;
} else {
queryParams.orderByColumn = '';
queryParams.isAsc = undefined;
}
queryParams.pageNum = 1;
getList();
};
const handleQuery = () => {
queryParams.pageNum = 1;
getList();
};
const resetQuery = () => {
queryParams.keyword = '';
queryParams.searchField = 'all';
queryParams.category = '';
queryParams.type = '';
queryParams.company = '';
queryParams.isEnabled = undefined;
queryParams.orderByColumn = '';
queryParams.isAsc = undefined;
queryParams.has_stock = '';
selectedItems.value = [];
tableRef.value?.clearSelection();
isBatchMode.value = false;
handleQuery();
};
const handleSizeChange = (command: 'large' | 'default' | 'small') => {
tableSize.value = command;
};
const handlePageSizeChange = (val: number) => {
queryParams.pageSize = val;
queryParams.pageNum = 1;
getList();
};
const handlePageCurrentChange = (val: number) => {
queryParams.pageNum = val;
getList();
};
const handleAdd = () => {
resetForm();
dialog.title = '新增基础信息';
dialog.visible = true;
};
const handleEdit = (row: MaterialBaseVO) => {
resetForm();
dialog.title = '编辑基础信息';
dialog.visible = true;
nextTick(() => {
const data = JSON.parse(JSON.stringify(row));
Object.assign(form.value, data);
// 深拷贝保存原始数据用于脏检查
originalForm.value = JSON.parse(JSON.stringify(data));
if (data.category) {
const parts = data.category.split('/');
if (parts.length > 0) {
tempCategorySuffix.value = parts.pop() || '';
tempCategoryPrefix.value = parts;
} else {
tempCategoryPrefix.value = [];
tempCategorySuffix.value = data.category;
}
} else {
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
}
const images = row.generalImage || [];
const manuals = row.generalManual || [];
const imgFiles = images.filter(u => !isExternalLink(u));
const imgLinks = images.filter(u => isExternalLink(u));
const manualFiles = manuals.filter(u => !isExternalLink(u));
const manualLinks = manuals.filter(u => isExternalLink(u));
fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
imageExternalUrl.value = imgLinks.length > 0 ? imgLinks[0] : '';
fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
manualExternalUrl.value = manualLinks.length > 0 ? manualLinks[0] : '';
});
};
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name && item.id !== form.value.id)) {
ElMessage.error(`已存在名称为 "${name}" 的基础信息!`);
return true;
}
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec, category: '', type: '', company: '' });
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec && item.id !== form.value.id)) {
ElMessage.error(`已存在规格/编号为 "${spec}" 的基础信息!`);
return true;
}
} catch (e) {
return false;
}
return false;
};
const isArraysEqual = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, idx) => val === sortedB[idx]);
};
const buildPartialPayload = (current: any, original: any): any => {
const payload: any = { id: current.id };
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName'];
for (const key of compareFields) {
const currentVal = current[key];
const originalVal = original[key];
// 处理数组比较generalImage, generalManual
if (Array.isArray(currentVal) && Array.isArray(originalVal)) {
if (!isArraysEqual(currentVal, originalVal)) {
payload[key] = currentVal;
}
} else if (currentVal !== originalVal) {
payload[key] = currentVal;
}
}
return payload;
};
const submitForm = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) {
submitLoading.value = false;
return;
}
const finalImageList = form.value.generalImage.filter(item => !isExternalLink(item));
if (imageExternalUrl.value) finalImageList.push(imageExternalUrl.value);
const finalManualList = form.value.generalManual.filter(item => !isExternalLink(item));
if (manualExternalUrl.value) finalManualList.push(manualExternalUrl.value);
const prefixStr = tempCategoryPrefix.value.join('/');
const suffixStr = tempCategorySuffix.value.trim();
let fullCategory = '';
if (prefixStr && suffixStr) fullCategory = prefixStr + '/' + suffixStr;
else fullCategory = prefixStr || suffixStr;
// 构建最终表单数据
const finalForm = {
...form.value,
category: fullCategory,
generalImage: finalImageList,
generalManual: finalManualList
};
// 脏检查:只提交变更的字段
let payload: any;
if (form.value.id && originalForm.value) {
// 编辑模式:生成部分更新 payload
payload = buildPartialPayload(finalForm, originalForm.value);
// 如果分类被修改,需要确保包含在 payload 中
if (payload.category === undefined && fullCategory !== originalForm.value.category) {
payload.category = fullCategory;
}
} else {
// 新增模式:提交完整数据
payload = finalForm;
}
// 如果没有变更,提示用户
const changedKeys = Object.keys(payload).filter(k => k !== 'id');
if (changedKeys.length === 0) {
ElMessage.info('没有检测到数据变更,无需保存');
submitLoading.value = false;
dialog.visible = false;
return;
}
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
const actionText = form.value.id ? '修改' : '新增';
await requestApi(payload);
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
originalForm.value = null;
getList();
getOptionsList();
} catch (error: any) {
ElMessage.error(error.msg || '保存失败');
} finally {
submitLoading.value = false;
}
}
});
};
const cancel = () => {
dialog.visible = false;
resetForm();
};
// 快速基于此物料查看/创建 BOM
const createBomForMaterial = () => {
if (!form.value.id) {
return ElMessage.warning('请先保存物料基础信息后再操作');
}
const routeUrl = router.resolve({
path: '/bom',
query: {
create_for_id: form.value.id,
parent_name: form.value.name,
parent_spec: form.value.spec
}
});
window.open(routeUrl.href, '_blank');
};
const resetForm = () => {
form.value = JSON.parse(JSON.stringify(initForm));
fileListImage.value = [];
fileListManual.value = [];
tempCategoryPrefix.value = [];
tempCategorySuffix.value = '';
imageExternalUrl.value = '';
manualExternalUrl.value = '';
originalForm.value = null;
if (formRef.value) formRef.value.resetFields();
};
// 确保这里的布尔值切换逻辑正确匹配真伪值的切换
const handleStatusChange = (row: MaterialBaseVO) => {
row.statusLoading = true;
const text = row.isEnabled === true ? "启用" : "停用";
const updateData = { id: row.id, isEnabled: row.isEnabled };
updateMaterialBase(updateData)
.then(() => ElMessage.success(`已${text} "${row.name}"`))
.catch(() => { row.isEnabled = !row.isEnabled; }) // 回退时布尔值反转
.finally(() => { row.statusLoading = false; });
};
const handleDelete = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
`是否确认删除名称为 "${row.name}" 的数据项?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(() => {
delMaterialBase(row.id).then(() => {
ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
getList();
getOptionsList();
});
}).catch(() => {});
};
// --- 预警设置函数 ---
// 单条设置预警
const handleSetSingleWarning = (row: MaterialBaseVO) => {
warningDialog.selectedIds = [row.id];
warningDialog.selectedCount = 1;
// 如果已有预警设置则回显
warningForm.isEnabled = row.warningEnabled || false;
warningForm.redThreshold = row.warningRed;
warningForm.yellowThreshold = row.warningYellow;
warningForm.redEmails = (row as any).warningRedEmails || (row as any).redEmails || '';
warningForm.yellowEmails = (row as any).warningYellowEmails || (row as any).yellowEmails || '';
warningDialog.title = '设置预警';
warningDialog.visible = true;
};
// 标记预警物料已采购
const handleMarkOrdered = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
'确认已对该预警物料下单?标记后在途期间将不再发送预警邮件。',
'确认标记已采购',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await markWarningOrdered({ baseId: row.id, isOrdered: true });
ElMessage.success('已标记为已采购');
getList();
} catch (error: any) {
ElMessage.error(error?.msg || '标记失败');
}
}).catch(() => {});
};
// 提交预警设置
const submitWarning = async () => {
if (!warningFormRef.value) return;
await warningFormRef.value.validate();
// 安全转换数值null/undefined 默认转为 0
const yellow = Number(warningForm.yellowThreshold) || 0;
const red = Number(warningForm.redThreshold) || 0;
// 逻辑校验:启用预警时,黄色阈值必须大于红色阈值
if (warningForm.isEnabled && yellow !== 0 && red !== 0 && yellow <= red) {
ElMessage.warning('黄色阈值必须大于红色阈值');
return;
}
warningLoading.value = true;
try {
const data = warningDialog.selectedIds.map(baseId => ({
baseId,
isEnabled: warningForm.isEnabled,
redThreshold: red,
yellowThreshold: yellow,
redEmails: warningForm.redEmails || '',
yellowEmails: warningForm.yellowEmails || ''
}));
await batchSetWarning(data);
ElMessage.success('预警设置成功');
warningDialog.visible = false;
selectedItems.value = []; // 清除选中状态
tableRef.value?.clearSelection(); // 清除表格勾选状态
isBatchMode.value = false; // 退出批量模式
getList();
} catch (error: any) {
ElMessage.error(error?.msg || '设置失败');
} finally {
warningLoading.value = false;
}
};
// 提交批量质检设置
const submitBatchInspection = async () => {
if (inspectionDialog.selectedIds.length === 0) {
ElMessage.warning('请先勾选物料');
return;
}
inspectionLoading.value = true;
try {
await batchSetInspection({
ids: inspectionDialog.selectedIds,
isInspectionRequired: inspectionForm.isInspectionRequired
});
ElMessage.success('批量质检设置成功');
inspectionDialog.visible = false;
// 清理批量模式状态
isBatchMode.value = false;
batchActionType.value = '';
selectedItems.value = [];
tableRef.value?.clearSelection();
getList();
} catch (error: any) {
ElMessage.error(error?.msg || '设置失败');
} finally {
inspectionLoading.value = false;
}
};
// 表格行样式(根据预警状态)
const tableRowClassName = ({ row }: { row: MaterialBaseVO }) => {
if (row.warningStatus === 2) {
return 'danger-row'; // 红色预警
} else if (row.warningStatus === 1) {
return 'warning-row'; // 黄色预警
}
return '';
}
// --- 文件上传辅助函数 ---
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const isImageFile = (url: string) => { return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(url) }
const isCompressedFile = (url: string) => { return /\.(zip|rar|7z)$/i.test(url) }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && isImageFile(item)) }
const getNonImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item) && !isImageFile(item)) }
const truncateFileName = (name: string, maxLen = 12) => { return name.length > maxLen ? name.slice(0, maxLen - 3) + '...' : name }
const handleDownloadConfirm = (link: string) => {
const fileName = link.split('/').pop() || '文件';
ElMessageBox.confirm(`确认要下载/查看「${fileName}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
window.open(getImageUrl(link), '_blank');
}).catch(() => {});
}
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
'application/pdf',
'application/zip', 'application/x-zip-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
'application/x-7z-compressed',
'application/octet-stream' // 兼容某些浏览器对 .zip/.rar 的错误识别
].includes(rawFile.type);
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/GIF/PDF/ZIP/RAR/7Z');
return false;
}
const isCompressed = ['application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/vnd.rar', 'application/x-7z-compressed'].includes(rawFile.type);
const maxMB = 150;
if (rawFile.size / 1024 / 1024 > maxMB) {
ElMessage.error(`文件不能超过 ${maxMB}MB`);
return false;
}
return true;
}
const customUpload = async (options: any, targetField: 'generalImage' | 'generalManual') => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
isUploading.value = true
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
form.value[targetField].push(newUrl)
ElMessage.success('上传成功')
onSuccess(res)
} else {
ElMessage.error(res.msg || '上传失败');
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误');
onError(e)
}
finally { isUploading.value = false }
}
const handleRemoveImage = async (uploadFile: any, targetField: 'generalImage' | 'generalManual') => {
const fileName = uploadFile.name || uploadFile.url?.split('/').pop() || '此文件'
try {
await ElMessageBox.confirm(
`确认要删除「${fileName}」吗?删除后不可恢复。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return // 用户取消,不删除
}
try {
const urlToRemove = form.value[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form.value[targetField] = form.value[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) {
const filename = urlToRemove.split('/').pop();
if (filename) await deleteFile(filename)
}
ElMessage.success('已删除')
} catch (e) {
console.error(e)
ElMessage.error('删除失败')
}
}
const handlePreviewPicture = (uploadFile: any) => {
const fileUrl = uploadFile.url || uploadFile.response?.url || '';
if (isImageFile(fileUrl)) {
dialogImageUrl.value = getImageUrl(fileUrl);
dialogVisibleImage.value = true;
} else {
window.open(getImageUrl(fileUrl), '_blank');
}
}
const triggerCamera = (field: 'generalImage' | 'generalManual') => {
currentCameraField.value = field;
cameraDialogVisible.value = true;
}
const handleCameraConfirm = async (file: File) => {
if (!beforeAvatarUpload(file)) {
cameraDialogVisible.value = false;
return;
}
const formData = new FormData();
formData.append('file', file);
const loadingInstance = ElLoading.service({ text: '照片上传中...', background: 'rgba(0, 0, 0, 0.7)' });
try {
const res: any = await uploadFile(formData);
if (res.code === 200) {
const newUrl = res.data.url;
const field = currentCameraField.value;
form.value[field].push(newUrl);
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) };
if (field === 'generalImage') {
fileListImage.value.push(fileObj);
} else {
fileListManual.value.push(fileObj);
}
ElMessage.success('拍照上传成功');
cameraDialogVisible.value = false;
} else {
ElMessage.error(res.msg || '上传失败');
}
} catch (e) {
ElMessage.error('上传过程中发生异常');
} finally {
loadingInstance.close();
}
};
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' });
};
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1);
};
const applyAdvancedFilter = () => {
// Filter out empty conditions
const validConditions = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '');
queryParams.advancedFilters = validConditions;
advancedFilterVisible.value = false;
queryParams.pageNum = 1;
getList();
};
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }];
queryParams.advancedFilters = [];
advancedFilterVisible.value = false;
queryParams.pageNum = 1;
getList();
};
onMounted(() => {
// 1. 修复背景联动:直接对 reactive 对象赋值
if (route.query.keyword) {
queryParams.keyword = route.query.keyword as string;
queryParams.searchField = 'all';
}
// 先根据权限初始化列显示状态
initColumnPermissions();
// 此时 getList 会带着正确的 keyword 向后端请求过滤后的数据
getList();
getOptionsList();
// 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query);
if (route.query.edit_id) {
const editId = Number(route.query.edit_id);
const searchKeyword = (route.query.keyword as string) || '';
console.log('检测到 edit_id:', editId, '使用 keyword 搜索:', searchKeyword);
// 改用 keyword 而不是无效的 id 去向后端请求数据,确保目标物料在返回的列表中
listMaterialBase({ page: 1, pageSize: 50, keyword: searchKeyword }).then((res: any) => {
let rawData = res?.data?.list ?? res?.data?.items ?? res?.data ?? [];
if (!Array.isArray(rawData) && typeof rawData === 'object' && rawData !== null) {
rawData = [rawData];
}
const rows = Array.isArray(rawData) ? rawData : [];
// 3. 去掉危险的 rows[0] 兜底,严格匹配 ID
const targetRow = rows.find((r: any) => r.id === editId);
if (targetRow) {
console.log('找到精准目标物料,准备弹窗:', targetRow);
setTimeout(() => {
handleEdit(targetRow);
}, 800);
} else {
console.warn('未能在搜索结果中匹配到对应 ID 的物料,可能 keyword 与 ID 不匹配');
}
}).catch((error: any) => {
console.error('自动获取物料详情失败', error);
});
}
});
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.right-toolbar {
display: flex;
align-items: center;
}
.column-setting-list {
display: flex;
flex-direction: column;
}
.pagination-container {
margin-top: 15px;
display: flex;
justify-content: flex-start;
}
.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; }
.file-preview-cell { display: flex; align-items: center; justify-content: center; position: relative; }
.more-badge { position: absolute; top: -5px; right: -5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 4px; font-size: 10px; transform: scale(0.9); }
/* 上传文件项样式 - 非图片文件显示 */
.upload-file-item { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.upload-file-item .el-upload-list__item-thumbnail { width: 100%; height: 100%; object-fit: cover; }
.upload-file-item .file-thumbnail { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #606266; }
.upload-file-item .file-thumbnail .file-name { font-size: 10px; margin-top: 4px; text-align: center; padding: 0 4px; word-break: break-all; max-width: 90px; }
.upload-file-item .el-upload-list__item-actions { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); opacity: 0; transition: opacity 0.3s; }
.upload-file-item:hover .el-upload-list__item-actions { opacity: 1; }
.upload-file-item .el-upload-list__item-actions .el-icon { color: #fff; font-size: 20px; cursor: pointer; margin: 0 4px; }
.upload-add-trigger { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
/* ================================================================
Element Plus 表格预警行样式 & 固定列重叠修复
================================================================ */
/* 黄色预警行底色 (全覆盖) */
:deep(.el-table .warning-row),
:deep(.el-table .warning-row > td.el-table__cell) {
background-color: #fcedc4 !important; /* 明显的黄色 */
}
/* 红色预警行底色 (全覆盖) */
:deep(.el-table .danger-row),
:deep(.el-table .danger-row > td.el-table__cell) {
background-color: #fcd3d3 !important; /* 明显的红色 */
}
/* 固定列的按钮容器底色跟随所在行的背景色,视觉无缝融合 */
:deep(.el-table .el-table__cell.is-fixed) {
background-color: inherit !important;
}
/* 按钮间距微调,更紧凑 */
:deep(.el-table .el-table__cell.is-fixed .cell) {
display: flex;
gap: 6px;
justify-content: flex-start; /* 左对齐更自然 */
flex-wrap: nowrap; /* 尽量不换行 */
}
</style>