Files
KCGL/inventory-web/src/views/material/buyOdoo.vue
yueli 2f7a81ff3d fix(material): 物料列表列加双重守卫,消除「有表头无数据」的空列
现象
----
收紧某字段读权限后,后端已把值抹成 null,但表格列照常渲染 —— 留下一列
全是「-」的空表头,白占屏幕宽度。反馈的「专业名称」即属此类。

两处前提与代码不符,先澄清
----
· 列展示设置里**已有**「专业名称」选项(list.vue:190),columns.commonName
  也**已在** columns 数组中(默认 visible: true)—— 无需补充。
  该选项与表格列都走 hasColPermission('commonName'),没有权限者看不到选项,
  与「没权限就看不见这列」的目标一致,并非缺陷。
· 真正的缺陷在同一处:**表格列只有 columns.X.visible,没有权限守卫**。
  16 个数据列里仅 isApprovalRequired 有(且用错了码)。

改动
----
一、给全部数据列补上 && hasColPermission('<key>')(两文件各 15 列;
    isApprovalRequired 原先写成 userStore.hasPermission('material_list:isApprovalRequired'),
    改为同一函数,避免复选框与表格列各用一套口径)。
二、修正 permissionMap 两处与后端读过滤的口径错位:
      isInspectionRequired: material_list:operation → material_list:isInspectionRequired
      isApprovalRequired:   material_list:operation → material_list:isApprovalRequired
    (以 app/utils/field_permissions.py 为准)

★ 为什么第 2 步是必须的:前端按 operation 判断会以为「有权限」,而后端其实
  按另一个码把字段抹成了 null —— 于是渲染出一列全是「-」的空表头。
  前端判定码必须与后端读过滤码**逐字段一致**,否则修了守卫也照样是空列。

验证
----
· 两个文件的数据列 100% 带权限守卫(grep 复核)
· 前端 permissionMap 与后端 STOCK_FIELD_RBAC_MAPPING 逐字段比对:
  16 项中 14 项完全一致;id / isEnabled 两项前端更严(后端为公开字段)——
  方向安全,不会产生空列
· 前端 vite build 通过
2026-09-17 11:20:59 +08:00

1690 lines
78 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-if="isSuperAdmin"
v-model="queryParams.company"
placeholder="所属公司"
clearable
filterable
default-first-option
style="width: 120px; margin-right: 10px;"
@change="handleQuery"
>
<el-option label="全部 (跨域)" value="ALL" />
<el-option v-for="item in companyOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-cascader
v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别"
clearable
filterable
style="width: 240px; margin-right: 10px;"
@change="handleQuery"
/>
<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-button type="primary" plain @click="imageSearchVisible = true">
<el-icon style="margin-right: 5px"><Picture /></el-icon>拍照识图
</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-button v-if="userStore.hasPermission('material_list:operation')" type="success" plain @click="showImportDialog = true" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Upload /></el-icon>批量导入
</el-button>
<el-button plain @click="expandAllGroups" style="margin-right: 8px">全部展开</el-button>
<el-button plain @click="collapseAllGroups" style="margin-right: 8px">全部折叠</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="display: flex; justify-content: space-between; align-items: center; font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
<span>列展示设置</span>
<el-checkbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
</div>
<el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" />
<el-checkbox v-if="hasColPermission('companyName')" v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-if="hasColPermission('name')" v-model="columns.name.visible" label="名称" />
<el-checkbox v-if="hasColPermission('commonName')" v-model="columns.commonName.visible" label="专业名称" />
<el-checkbox v-if="hasColPermission('category')" v-model="columns.category.visible" label="类别" />
<el-checkbox v-if="hasColPermission('type')" v-model="columns.type.visible" label="类型" />
<el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-if="hasColPermission('unit')" v-model="columns.unit.visible" label="单位" />
<el-checkbox v-if="hasColPermission('inventory')" v-model="columns.inventory.visible" label="库存数" />
<el-checkbox v-if="hasColPermission('available')" v-model="columns.available.visible" label="可用数" />
<el-checkbox v-if="hasColPermission('files')" v-model="columns.files.visible" label="资料" />
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
<el-checkbox v-if="hasColPermission('referencePrice')" v-model="columns.referencePrice.visible" label="参考价格" />
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
</div>
</el-popover>
</div>
</div>
<div v-loading="loading" class="odoo-view-container">
<el-collapse
v-model="activeCategories"
class="odoo-collapse"
@change="handleCollapseChange"
>
<el-collapse-item
v-for="group in groupedData"
:key="group.category"
:name="group.category"
>
<template #title>
<div class="odoo-group-header">
<span class="category-name">
{{ group.category || '未分类' }} ({{ group.count }})
<el-icon v-if="groupLoadingMap.get(group.category)" class="is-loading" style="margin-left:6px;font-size:14px;"><Loading /></el-icon>
</span>
</div>
</template>
<el-table
:ref="(el) => setTableRef(el, group.category)"
v-if="activeCategories.includes(group.category)"
:data="group.items"
border
stripe
row-key="id"
:size="tableSize"
:row-class-name="tableRowClassName"
@sort-change="handleSortChange"
@selection-change="(selection) => handleGroupSelectionChange(group.category, selection)"
style="width: 100%; border-top: none;"
>
<el-table-column v-if="isBatchMode" type="selection" width="55" :reserve-selection="true" />
<el-table-column v-if="columns.id.visible && hasColPermission('id')" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.companyName.visible && hasColPermission('companyName')" 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 && hasColPermission('name')" label="名称" min-width="160" show-overflow-tooltip sortable="custom">
<template #default="scope">
<span v-if="userStore.hasPermission('material_list:operation')" class="clickable-text" @click="handleEdit(scope.row)">
{{ scope.row.name }}
</span>
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column v-if="columns.commonName.visible && hasColPermission('commonName')" 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 && hasColPermission('category')" 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 && hasColPermission('type')" 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 && hasColPermission('spec')" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip sortable="custom" />
<el-table-column v-if="columns.unit.visible && hasColPermission('unit')" prop="unit" label="单位" min-width="80" align="center" sortable="custom" />
<el-table-column v-if="columns.inventory.visible && hasColPermission('inventory')" 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 && hasColPermission('available')" 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 && hasColPermission('files')" 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
hide-on-click-modal
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
hide-on-click-modal
/>
<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 && hasColPermission('isEnabled')" 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 && hasColPermission('isInspectionRequired')" 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="columns.referencePrice.visible && hasColPermission('referencePrice')" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
<template #default="scope">
<span v-if="scope.row.referencePrice != null" class="money-text">{{ scope.row.referencePrice?.toFixed(2) }}</span>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" 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>
</el-collapse-item>
</el-collapse>
</div>
<el-dialog
v-model="dialog.visible"
width="1200px"
append-to-body
destroy-on-close
@close="cancel"
:close-on-click-modal="false"
: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>
<div style="display: flex; align-items: center; gap: 16px;">
<el-link
v-if="form.id"
type="primary"
:underline="false"
style="font-size: 14px;"
@click="handleSaveAs"
>
<el-icon style="margin-right: 4px"><DocumentCopy /></el-icon>另存为新项
</el-link>
<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>
</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="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-row>
<el-row>
<el-col :span="24">
<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="unit" v-if="hasFieldPermission('unit')">
<el-select
v-model="form.unit"
filterable
allow-create
default-first-option
placeholder="请选择或输入计量单位"
style="width: 100%"
>
<el-option
v-for="item in unitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</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="referencePrice" v-if="hasFieldPermission('referencePrice')">
<el-input-number v-model="form.referencePrice" :precision="2" :min="0" controls-position="right" style="width: 100%" placeholder="请输入参考价格" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
<div class="upload-container" id="upload-generalImage">
<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="form.productImageRemark"
type="textarea"
:rows="1"
:disabled="!canEditRemark"
:placeholder="canEditRemark ? '请输入产品图备注信息' : '无编辑权限,仅可查看'"
style="margin-top: 8px;"
clearable
/>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item>
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
<div class="upload-container" id="upload-generalManual">
<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="form.manualLinkRemark"
type="textarea"
:rows="1"
:disabled="!canEditRemark"
:placeholder="canEditRemark ? '请输入说明书备注信息' : '无编辑权限,仅可查看'"
style="margin-top: 8px;"
clearable
/>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</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%" :close-on-click-modal="false" :close-on-press-escape="false">
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false"
/>
</el-dialog>
<ImageSearchDialog
v-model="imageSearchVisible"
@use="handleImageSearchUse"
@view="handleImageSearchView"
/>
<el-dialog v-model="warningDialog.visible" :title="warningDialog.title" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<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 :close-on-click-modal="false" :close-on-press-escape="false">
<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>
<!-- 批量导入弹窗 -->
<ImportDialog v-model="showImportDialog" import-type="material" @success="getList" />
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture, FolderOpened, Loading, Upload } 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,
getMaterialUnitsAPI,
getOdooSummary
} from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload';
import WebRtcCamera from '@/components/Camera/WebRtcCamera.vue';
import ImageSearchDialog from '@/components/ImageSearchDialog.vue';
import ImportDialog from '@/components/ImportDialog.vue';
import { imageSearch as imageSearchApi, type ImageSearchItem } from '@/api/common/upload';
const userStore = useUserStore();
const isSuperAdmin = computed(() => userStore.role === 'SUPER_ADMIN');
// ★ 附件备注(产品图备注 / 说明书备注)的**写**权限,与读权限分离。
// 与 list.vue、后端 base.py 三处同口径:
// 读 → material_list:files 区块整体可见6 个角色)
// 写 → material_list:remark_edit 3 个核心管理角色)
// 无写权限时输入框置灰但仍可见 —— 读权限决定可见性。
const canEditRemark = computed(() => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
return userStore.hasPermission('material_list:remark_edit');
});
// --- 类型定义 ---
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;
referencePrice?: number;
}
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 submitLoading = ref(false);
const isUploading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
const advancedFilterVisible = ref(false);
const imageSearchVisible = ref(false);
const showImportDialog = 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' },
{ value: 'referencePrice', label: '参考价格', perm: 'material_list:referencePrice' }
];
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 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);
// ================= Odoo 分组核心逻辑(懒加载架构 v2 =================
const queryParams = reactive<QueryParams>({
pageNum: 1,
pageSize: 50, // ★ 恢复正常分页大小,不再用 9999
keyword: '',
searchField: 'all',
category: '',
type: '',
company: 'ALL',
isEnabled: undefined,
orderByColumn: '',
isAsc: undefined,
advancedFilters: [],
has_stock: ''
});
// ★ 新增Odoo 分组摘要(来自 /odoo-summary API
interface GroupSummary {
category: string;
count: number;
}
const groupSummary = ref<GroupSummary[]>([]);
// ★ 新增:分组数据缓存 Map<category, {items, total}>
const groupCache = ref<Map<string, { items: MaterialBaseVO[]; total: number }>>(new Map());
// ★ 新增:分组加载状态
const groupLoadingMap = ref<Map<string, boolean>>(new Map());
// 当前展开的分类(支持搜索全局过滤时自动全部折叠)
const lastKeyword = ref('');
// 计算属性:基于缓存构建分组数据
const groupedData = computed(() => {
if (!groupSummary.value.length) return [];
return groupSummary.value.map(summary => {
const cached = groupCache.value.get(summary.category);
return {
category: summary.category,
count: summary.count,
items: cached?.items ?? [],
total: cached?.total ?? summary.count,
loaded: !!cached
};
});
});
// 折叠面板展开状态
const activeCategories = ref<string[]>([]);
const expandAllGroups = () => {
// 展开全部 → 触发所有分组的懒加载
activeCategories.value = groupSummary.value.map(g => g.category);
groupSummary.value.forEach(g => loadGroupItems(g.category));
};
const collapseAllGroups = () => {
activeCategories.value = [];
};
// ================= 跨组表格批量选中处理 =================
const isBatchMode = ref(false);
const batchActionType = ref('');
const selectedItems = ref<MaterialBaseVO[]>([]);
const groupSelections = ref<Record<string, MaterialBaseVO[]>>({});
const tableRefs = ref<Record<string, any>>({});
const setTableRef = (el: any, category: string) => {
if (el) tableRefs.value[category] = el;
};
// 代理所有表格的勾选事件
const handleGroupSelectionChange = (category: string, selection: MaterialBaseVO[]) => {
groupSelections.value[category] = selection;
selectedItems.value = Object.values(groupSelections.value).flat();
};
const enterBatchMode = (actionType: string) => {
batchActionType.value = actionType;
selectedItems.value = [];
groupSelections.value = {};
isBatchMode.value = true;
Object.values(tableRefs.value).forEach(t => t?.clearSelection?.());
};
const cancelBatchMode = () => {
isBatchMode.value = false;
batchActionType.value = '';
selectedItems.value = [];
groupSelections.value = {};
Object.values(tableRefs.value).forEach(t => t?.clearSelection?.());
};
const confirmBatchSelection = () => {
if (selectedItems.value.length === 0) {
return ElMessage.warning('请先勾选需要操作的物料');
}
const selected = selectedItems.value;
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 = '';
};
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 }, referencePrice: { visible: true }, warningStatus: { visible: true }
});
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',
// ★ 与后端读过滤field_permissions.py对齐原先写 material_list:operation
// 会与后端口径不一致,渲染出一列全是「-」的空表头
isInspectionRequired: 'material_list:isInspectionRequired', referencePrice: 'material_list:referencePrice',
warningStatus: 'material_list:view_warning'
};
const getStorageKey = () => `MOM_BASIC_INFO_COLS_${userStore.username || 'DEFAULT'}`;
const hasColPermission = (key: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
const code = permissionMap[key];
return code ? !!userStore.hasPermission(code) : true;
};
const isAllSelected = computed(() => {
const allowedKeys = Object.keys(columns).filter(k => hasColPermission(k));
return allowedKeys.length > 0 && allowedKeys.every(k => columns[k as keyof typeof columns].visible);
});
const isIndeterminate = computed(() => {
const allowedKeys = Object.keys(columns).filter(k => hasColPermission(k));
const checkedCount = allowedKeys.filter(k => columns[k as keyof typeof columns].visible).length;
return checkedCount > 0 && checkedCount < allowedKeys.length;
});
const handleCheckAllChange = (val: boolean) => {
Object.keys(columns).forEach(key => {
if (hasColPermission(key)) columns[key as keyof typeof columns].visible = val;
});
};
watch(columns, (newVal) => {
localStorage.setItem(getStorageKey(), JSON.stringify(newVal));
}, { deep: true });
const initColumnPermissions = () => {
const cachedData = localStorage.getItem(getStorageKey());
let parsedCache: Record<string, any> | null = null;
if (cachedData) {
try { parsedCache = JSON.parse(cachedData); } catch (e) { console.error('解析列缓存失败', e); }
}
Object.keys(columns).forEach(key => {
const colKey = key as keyof typeof columns;
const hasPerm = hasColPermission(colKey);
if (!hasPerm) {
columns[colKey].visible = false;
} else {
if (parsedCache && parsedCache[colKey] !== undefined) {
columns[colKey].visible = parsedCache[colKey].visible;
}
}
});
};
const hasFieldPermission = (field: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
const code = permissionMap[field];
if (!code) return true;
return userStore.hasPermission(code);
};
const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const unitOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]);
const searchCategoryPath = computed({
get() { return queryParams.category ? queryParams.category.split('/') : []; },
set(val: string[] | null) { queryParams.category = val && val.length > 0 ? val.join('/') : ''; }
});
const categoryCascaderRef = ref<any>(null);
const onCategoryChange = () => {
if (!categoryCascaderRef.value) return;
categoryCascaderRef.value.togglePopperVisible(false);
try {
const nodes = categoryCascaderRef.value.getCheckedNodes?.() || [];
const node = nodes[0];
const label: string = (node && node.label) || '';
const match = label.match(/[a-zA-Z0-9]+$/);
if (match) form.value.spec = match[0];
} catch (e) { console.error('提取类别编码后缀失败', e); }
};
const tempCategoryPrefix = ref<string[]>([]);
const tempCategorySuffix = ref<string>('');
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,
referencePrice: undefined as number | undefined,
productImageRemark: '', manualLinkRemark: ''
};
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 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 fetchUnitList = () => {
getMaterialUnitsAPI().then((res: any) => {
if (res.code === 200) unitOptions.value = res.data || [];
}).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;
cb(results.map(item => ({ value: item })));
};
const querySearchType = (queryString: string, cb: any) => {
const results = queryString ? typeOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase())) : typeOptions.value;
cb(results.map(item => ({ value: item })));
};
// ★ 新增:挂载时调用 - 获取分组摘要
const fetchOdooSummary = async () => {
loading.value = true;
try {
const params: any = {};
if (queryParams.keyword) params.keyword = queryParams.keyword;
if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
const res: any = await getOdooSummary(params);
if (res?.code === 200) {
groupSummary.value = res.data ?? [];
// 搜索条件变更 → 清除旧缓存,折叠所有分组
groupCache.value = new Map();
groupLoadingMap.value = new Map();
if (queryParams.keyword !== lastKeyword.value) {
activeCategories.value = [];
lastKeyword.value = queryParams.keyword;
}
}
} catch (err) {
console.error('获取 Odoo 摘要失败', err);
} finally {
loading.value = false;
}
};
// ★ 新增:展开分组时懒加载该分类下的数据
const loadGroupItems = async (category: string) => {
// 已加载则跳过
if (groupCache.value.has(category)) return;
// 正在加载中则跳过
if (groupLoadingMap.value.get(category)) return;
groupLoadingMap.value.set(category, true);
try {
const params: any = {
pageNum: 1,
pageSize: 9999, // 单分类全量加载(分类内数据量通常可控)
category: category,
};
if (queryParams.keyword) params.keyword = queryParams.keyword;
if (queryParams.type) params.type = queryParams.type;
if (queryParams.isEnabled !== undefined) params.isEnabled = queryParams.isEnabled;
if (queryParams.company && queryParams.company !== 'ALL') params.company = queryParams.company;
if (queryParams.orderByColumn) params.orderByColumn = queryParams.orderByColumn;
if (queryParams.isAsc) params.isAsc = queryParams.isAsc;
const res: any = await listMaterialBase(params);
if (res?.code === 200 && res.data) {
groupCache.value.set(category, {
items: res.data.items ?? [],
total: res.data.total ?? 0
});
}
} catch (err) {
console.error(`加载分类 [${category}] 失败`, err);
} finally {
groupLoadingMap.value.set(category, false);
}
};
// ★ 监听 collapse 展开事件 → 触发懒加载
const handleCollapseChange = (val: string | string[]) => {
// val 是当前所有展开的分类名数组
if (Array.isArray(val)) {
val.forEach(cat => loadGroupItems(cat));
} else if (val) {
loadGroupItems(val);
}
};
const getList = () => {
// Odoo 页面不再一次性全量加载,改为 fetchOdooSummary
fetchOdooSummary();
};
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;
const now = new Date();
const filename = `库存统计_${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}${String(now.getSeconds()).padStart(2,'0')}.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(() => { getList(); }, 500);
};
const handleSortChange = ({ column, prop, order }: any) => {
const sortableColumns = ['inventoryCount', 'availableCount', 'referencePrice', '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;
}
// ★ 排序时:清除已展开分组缓存 + 重新加载(带排序参数)
activeCategories.value.forEach(cat => groupCache.value.delete(cat));
activeCategories.value.forEach(cat => loadGroupItems(cat));
};
const handleQuery = () => { getList(); };
const resetQuery = () => {
queryParams.keyword = ''; queryParams.searchField = 'all'; queryParams.category = '';
queryParams.type = ''; queryParams.company = isSuperAdmin.value ? 'ALL' : ''; queryParams.isEnabled = undefined;
queryParams.orderByColumn = ''; queryParams.isAsc = undefined; queryParams.has_stock = '';
selectedItems.value = []; groupSelections.value = {};
Object.values(tableRefs.value).forEach(t => t?.clearSelection?.());
isBatchMode.value = false;
handleQuery();
};
const handleSizeChange = (command: 'large' | 'default' | 'small') => { tableSize.value = command; };
// === Odoo 取消了独立的分页组件操作 ===
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 || [];
// 只显示真正的上传文件,备注字段已由 Object.assign 自动回显
const imgFiles = images.filter(u => isInternalFile(u));
const manualFiles = manuals.filter(u => isInternalFile(u));
fileListImage.value = imgFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
fileListManual.value = manualFiles.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }));
});
};
const handleSaveAs = () => {
if (!form.value.id) return;
delete form.value.id; dialog.title = '新增基础信息'; originalForm.value = null;
ElMessage.success('已成功复制当前数据,已切换至【新增】模式。请修改特定信息(如规格型号)后点击确定保存。');
};
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', 'productImageRemark', 'manualLinkRemark'];
for (const key of compareFields) {
const currentVal = current[key]; const originalVal = original[key];
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 => isInternalFile(item));
const finalManualList = form.value.generalManual.filter(item => isInternalFile(item));
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 = buildPartialPayload(finalForm, originalForm.value);
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(); };
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 = '';
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("删除成功"); 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();
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;
cancelBatchMode(); 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;
cancelBatchMode(); 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 isInternalFile = (str: string) => { return str && (str.includes('/api/v1/common/files') || /\.(jpg|jpeg|png|gif|webp|bmp|pdf|zip|rar|7z)$/i.test(str)) }
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' ].includes(rawFile.type);
if (!isTypeValid) { ElMessage.error('仅支持 JPG/PNG/GIF/PDF/ZIP/RAR/7Z'); return false; }
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)
const targetList = targetField === 'generalImage' ? fileListImage : fileListManual
const staleIndex = targetList.value.findIndex(f => f.raw === file)
if (staleIndex !== -1) targetList.value.splice(staleIndex, 1)
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
if (targetField === 'generalImage') fileListImage.value.push(fileObj)
else fileListManual.value.push(fileObj)
ElMessage.success('上传成功')
} else { ElMessage.error(res.msg || '上传失败'); onError(new Error(res.msg)) }
} catch (e) { ElMessage.error('网络错误'); onError(e) } finally { isUploading.value = false }
}
// 粘贴上传处理器(仅处理实际图片文件,不再将链接自动填入备注框)
usePasteUpload(customUpload, 'generalImage', '#upload-generalImage')
usePasteUpload(customUpload, 'generalManual', '#upload-generalManual')
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 handleImageSearchUse = () => { /* 暂时未实现 */ }
const handleImageSearchView = (item: any) => {
imageSearchVisible.value = false; queryParams.keyword = item.spec_model; handleQuery(); ElMessage.success(`已应用物料规格: ${item.spec_model} 进行搜索`);
};
const addCondition = () => { advancedConditions.value.push({ field: '', operator: '', value: '' }); };
const removeCondition = (index: number) => { advancedConditions.value.splice(index, 1); };
const applyAdvancedFilter = () => {
const validConditions = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '');
queryParams.advancedFilters = validConditions; advancedFilterVisible.value = false; getList();
};
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }];
queryParams.advancedFilters = []; advancedFilterVisible.value = false; getList();
};
watch(
() => route.query.keyword,
(newKeyword) => {
if (newKeyword) {
queryParams.keyword = newKeyword as string; queryParams.searchField = 'all'; getList();
router.replace({ path: route.path, query: {} });
}
},
{ immediate: true }
);
onMounted(() => {
initColumnPermissions();
if (!route.query.keyword) fetchOdooSummary();
getOptionsList(); fetchUnitList();
if (route.query.edit_id) {
const editId = Number(route.query.edit_id); const searchKeyword = (route.query.keyword as string) || '';
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 : [];
const targetRow = rows.find((r: any) => r.id === editId);
if (targetRow) setTimeout(() => { handleEdit(targetRow); }, 800);
}).catch(() => {});
}
});
</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;
}
/* ================= Odoo 核心样式注入 ================= */
.odoo-view-container {
margin-top: 15px;
}
.odoo-collapse {
border: none;
}
:deep(.el-collapse-item__header) {
background: #f3f4f6;
padding: 0 15px;
font-weight: bold;
border-radius: 4px;
margin-bottom: 5px;
border-bottom: 1px solid #e5e7eb;
}
:deep(.el-collapse-item__wrap) {
border: none;
}
.odoo-group-header {
display: flex;
justify-content: space-between;
width: 100%;
}
.category-name {
display: flex;
align-items: center;
}
:deep(.el-table) {
--el-table-border-color: #e5e7eb;
border-left: none;
border-right: none;
}
/* =================================================== */
.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%; }
: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; }
.clickable-text { color: #409EFF; cursor: pointer; font-weight: 500; text-decoration: underline; }
.clickable-text:hover { color: #66b1ff; }
</style>