现象
----
收紧某字段读权限后,后端已把值抹成 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 通过
1690 lines
78 KiB
Vue
1690 lines
78 KiB
Vue
<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">红色阈值 < 库存 ≤ 此值时显示黄色预警</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> |