13 Commits

Author SHA1 Message Date
DXC
8bb3e58b44 前端全局:<el-select remote> 三道防线扩展到 BOM 配方/采购/采购入库/售后入库
- 第一道防线:<el-select> 模板显式补充 reserve-keyword="true" / default-first-option="true",覆盖 4 文件 5 实例

- 第二道防线:handleRemoteSearch / handleSearchMaterial 首行深度净化 query(零宽字符/控制字符/BOM/不可见 Unicode)

- 第三道防线:handleVisibleChange / handleMaterialDropdownVisible 加竞态守卫,已有 searchKeyword 或 options 非空时跳过默认列表加载;带 debounce 的场景主动 clearTimeout 互斥

- service.vue 原本缺少 searchKeyword 状态,本轮新增 ref('') 专供 el-select 守卫使用

- BomManage.vue 父件/子件共用 handleVisibleChange,两套守卫分别按 parentQueryParams.keyword 和 state.queryParams.keyword 隔离判断
2026-06-04 16:44:59 +08:00
DXC
cdac915a4b 半成品/成品入库:物料/BOM 远程搜索粘贴失效 Bug 修复(三层防御)
- 深度净化 query:剔除零宽字符(U+200B-U+200D)/BOM(U+FEFF)/控制字符(U+0000-U+001F,U+007F-U+009F),应对外部复制粘贴混入隐形 Unicode 导致 ilike 匹配失败的场景

- 显式 reserve-keyword="true" / default-first-option="true":物料与 BOM 两个 <el-select> 全部显式标注,防止 Element 框架在选择后清空关键字(BOM 下拉框原缺失)

- handleMaterialDropdownVisible 竞态守卫:粘贴时 remote-method 与 @visible-change 同时触发,后者会 clearTimeout 前者的 debounce 定时器并加载默认列表覆盖结果。新增 !searchKeyword 守卫 + 主动 clearTimeout 互斥
2026-06-04 16:34:36 +08:00
DXC
8a2da1ac1e 半成品/成品入库:BOM 编号下拉按父件规格联动过滤(前后端双端改造)
- 后端 /inbound/{semi,product}/search-bom 增加 parent_spec 可选参数,Service 层在 MaterialBase.spec_model 上加等值过滤
2026-06-04 16:01:48 +08:00
DXC
332ae3c4cf 基础信息页:产品图/说明书上传后预览不显示修复 + 新增 Ctrl+V 粘贴蓝字提示
- customUpload 改为手动 push:移除 onSuccess(res) 调用,规避 el-upload 2.13.1 handleSuccess 未从 res.data.url 提取 url 的问题
2026-06-04 15:43:38 +08:00
DXC
d51c6f147f 前端:所有 <el-dialog> 统一添加 :close-on-click-modal="false" 防误触关闭(保留 Esc 关闭) 2026-06-04 15:16:16 +08:00
DXC
2977acbae7 BOM 配方管理:禁止编辑原数据,引入另存为(深拷贝+清 ID)+ 只读查看模式(点击编号进只读弹窗) 2026-06-04 14:44:29 +08:00
DXC
90eed24441 基础信息页:编辑弹窗新增另存为新项功能(清主键+切标题+清脏检查基准,复用 addMaterialBase 接口) 2026-06-04 14:07:34 +08:00
DXC
91444034e0 基础信息页:将出厂名称展示文案统一改为专业名称(5 处,变量名/接口字段保持不变) 2026-06-04 13:32:52 +08:00
DXC
8f901e3f08 基础信息页:类别→规格型号自动提取正则扩展为支持字母+数字(如 Opt9) 2026-06-04 13:27:00 +08:00
DXC
bac670ef7a 基础信息页:计量单位改 el-select(下拉历史+手动输入);表单排版重排为 4 行(类别占满行);类别末级英文后缀自动填规格型号 2026-06-04 13:22:51 +08:00
DXC
1c0c02fd36 基础信息页新增/编辑弹窗隐藏“可见等级”表单项(v-if=“false”,代码保留可恢复) 2026-06-04 11:40:34 +08:00
DXC
fffee9d964 入库管理三页面类别搜索中间节点支持子级匹配(buy/semi/product 类别过滤改为 ilike 前缀,与基础信息页一致) 2026-06-04 11:31:44 +08:00
DXC
a3d47f6328 入库管理三页面类别搜索统一为级联选择器;基础信息“俗名”改名为“出厂名称” 2026-06-04 11:05:58 +08:00
18 changed files with 563 additions and 158 deletions

View File

@ -98,6 +98,24 @@ def search_base():
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 1.1 计量单位字典接口 (GET /api/v1/inbound/base/units)
# ==============================================================================
@inbound_base_bp.route('/units', methods=['GET'])
@permission_required('material_list')
def get_unit_dict():
"""
获取所有已存在的非空计量单位(去重 + 排序),用于前端
新增/编辑弹窗中"计量单位"下拉框的历史记录。
"""
try:
units = MaterialBaseService.get_distinct_units()
return jsonify({"code": 200, "msg": "success", "data": units})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ============================================================================== # ==============================================================================
# 2. 列表接口 (GET /api/v1/inbound/base/list) # 2. 列表接口 (GET /api/v1/inbound/base/list)
# ============================================================================== # ==============================================================================

View File

@ -60,7 +60,8 @@ def search_base():
def search_bom(): def search_bom():
try: try:
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = ProductInboundService.search_bom_options(keyword) parent_spec = request.args.get('parent_spec', None)
data = ProductInboundService.search_bom_options(keyword, parent_spec=parent_spec)
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()

View File

@ -60,7 +60,8 @@ def search_base():
def search_bom(): def search_bom():
try: try:
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
data = SemiInboundService.search_bom_options(keyword) parent_spec = request.args.get('parent_spec', None)
data = SemiInboundService.search_bom_options(keyword, parent_spec=parent_spec)
return jsonify({"code": 200, "msg": "success", "data": data}) return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()

View File

@ -528,6 +528,29 @@ class MaterialBaseService:
traceback.print_exc() traceback.print_exc()
return {"categories": [], "types": [], "companies": []} return {"categories": [], "types": [], "companies": []}
@staticmethod
def get_distinct_units():
"""
获取所有已存在且非空的计量单位(去重 + 排序)。
用于前端"基础信息"新增/编辑弹窗的"计量单位"下拉历史记录。
SQL 语义:
SELECT DISTINCT unit FROM material_base
WHERE unit IS NOT NULL AND unit != ''
ORDER BY unit ASC
"""
try:
rows = db.session.query(MaterialBase.unit) \
.filter(MaterialBase.unit.isnot(None), MaterialBase.unit != '') \
.distinct() \
.all()
sorted_units = sorted([u[0] for u in rows if u[0]])
return sorted_units
except Exception as e:
traceback.print_exc()
print(f"查询计量单位字典失败: {e}")
return []
@staticmethod @staticmethod
def create_material(data): def create_material(data):
"""新增基础信息""" """新增基础信息"""

View File

@ -382,7 +382,8 @@ class BuyInboundService:
# 2. 类别独立搜索 # 2. 类别独立搜索
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
# 3. 类型独立搜索 # 3. 类型独立搜索
if material_type and material_type.strip(): if material_type and material_type.strip():

View File

@ -66,7 +66,7 @@ class ProductInboundService:
return {"items": [], "total": 0, "page": 1, "has_next": False} return {"items": [], "total": 0, "page": 1, "has_next": False}
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword, parent_spec=None):
from app.models.bom import BomTable from app.models.bom import BomTable
try: try:
query = db.session.query( query = db.session.query(
@ -79,6 +79,9 @@ class ProductInboundService:
if hasattr(BomTable, 'is_enabled'): if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
if parent_spec:
query = query.filter(MaterialBase.spec_model == parent_spec)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
query = query.filter( query = query.filter(
@ -349,7 +352,8 @@ class ProductInboundService:
sku_str = f'%{sku.strip()}%' sku_str = f'%{sku.strip()}%'
query = query.filter(StockProduct.sku.ilike(sku_str)) query = query.filter(StockProduct.sku.ilike(sku_str))
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())

View File

@ -71,7 +71,7 @@ class SemiInboundService:
return {"items": [], "total": 0, "page": 1, "has_next": False} return {"items": [], "total": 0, "page": 1, "has_next": False}
@staticmethod @staticmethod
def search_bom_options(keyword): def search_bom_options(keyword, parent_spec=None):
from app.models.bom import BomTable from app.models.bom import BomTable
try: try:
query = db.session.query( query = db.session.query(
@ -84,6 +84,9 @@ class SemiInboundService:
if hasattr(BomTable, 'is_enabled'): if hasattr(BomTable, 'is_enabled'):
query = query.filter(BomTable.is_enabled == True) query = query.filter(BomTable.is_enabled == True)
if parent_spec:
query = query.filter(MaterialBase.spec_model == parent_spec)
if keyword: if keyword:
kw = f'%{keyword}%' kw = f'%{keyword}%'
query = query.filter( query = query.filter(
@ -439,7 +442,8 @@ class SemiInboundService:
sku_str = f'%{sku.strip()}%' sku_str = f'%{sku.strip()}%'
query = query.filter(StockSemi.sku.ilike(sku_str)) query = query.filter(StockSemi.sku.ilike(sku_str))
if category and category.strip(): if category and category.strip():
query = query.filter(MaterialBase.category == category.strip()) # 级联选择器:中间节点用前缀匹配,与 base_service.get_list 行为一致
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
if material_type and material_type.strip(): if material_type and material_type.strip():
query = query.filter(MaterialBase.material_type == material_type.strip()) query = query.filter(MaterialBase.material_type == material_type.strip())

View File

@ -251,7 +251,7 @@ const handleLogout = () => {
v-model="profileDialogVisible" v-model="profileDialogVisible"
title="个人中心" title="个人中心"
width="480px" width="480px"
:close-on-click-modal="!passwordLoading" :close-on-click-modal="false"
destroy-on-close destroy-on-close
class="profile-dialog" class="profile-dialog"
> >
@ -331,7 +331,7 @@ const handleLogout = () => {
</el-dialog> </el-dialog>
<!-- 绑定/修改邮箱弹窗 --> <!-- 绑定/修改邮箱弹窗 -->
<el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" @close="resetEmailForm"> <el-dialog v-model="emailDialogVisible" title="绑定/修改邮箱" width="400px" :close-on-click-modal="false" @close="resetEmailForm">
<el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px"> <el-form :model="emailForm" :rules="emailRules" ref="emailFormRef" label-width="80px">
<el-form-item label="新邮箱" prop="email"> <el-form-item label="新邮箱" prop="email">
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" /> <el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />

View File

@ -43,11 +43,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
} }
// 搜索BOM // 搜索BOM
export function searchBom(keyword: string) { export function searchBom(keyword: string, parent_spec?: string) {
return request({ return request({
url: '/inbound/product/search-bom', url: '/inbound/product/search-bom',
method: 'get', method: 'get',
params: { keyword } params: { keyword, parent_spec }
}) })
} }

View File

@ -45,11 +45,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
} }
// 5.5 搜索BOM (新增) // 5.5 搜索BOM (新增)
export function searchBom(keyword: string) { export function searchBom(keyword: string, parent_spec?: string) {
return request({ return request({
url: '/inbound/semi/search-bom', url: '/inbound/semi/search-bom',
method: 'get', method: 'get',
params: { keyword } params: { keyword, parent_spec }
}) })
} }

View File

@ -87,3 +87,11 @@ export function markWarningOrdered(data: { baseId: number; isOrdered: boolean })
data data
}) })
} }
// 9. 获取计量单位字典 (新增/编辑弹窗下拉历史)
export function getMaterialUnitsAPI() {
return request({
url: '/inbound/base/units',
method: 'get'
})
}

View File

@ -35,7 +35,7 @@
<el-table :data="group.items" border style="width: 100%"> <el-table :data="group.items" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable> <el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
<template #default="{ row }"> <template #default="{ row }">
<span style="cursor: pointer; color: #409EFF;" @click="handleEdit(row)">{{ row.bom_no }}</span> <span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip /> <el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
@ -51,9 +51,8 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" /> <el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right"> <el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="200" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button> <el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button> <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template> </template>
@ -64,7 +63,7 @@
</div> </div>
</el-card> </el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules"> <el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
<el-row :gutter="20"> <el-row :gutter="20">
@ -76,13 +75,14 @@
placeholder="请搜索并选择父件" placeholder="请搜索并选择父件"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')" :remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
:loading="selectLoading" :loading="selectLoading"
style="width: 100%" style="width: 100%"
:disabled="isEditMode" :disabled="isReadOnlyMode || isEditMode"
class="beautified-select" class="beautified-select"
popper-class="bom-loadmore-popper parent-popper" popper-class="bom-loadmore-popper parent-popper"
default-first-option="true"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')" @visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
@change="onParentChange" @change="onParentChange"
> >
@ -99,7 +99,7 @@
</el-option> </el-option>
</el-select> </el-select>
<el-link <el-link
v-if="form.parent_id" v-if="form.parent_id && !isReadOnlyMode"
type="primary" type="primary"
:underline="false" :underline="false"
style="margin-left: 12px; font-size: 13px;" style="margin-left: 12px; font-size: 13px;"
@ -114,7 +114,7 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="8"> <el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')"> <el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" /> <el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="isReadOnlyMode || !userStore.hasPermission('bom_manage:operation')" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="16"></el-col> <el-col :span="16"></el-col>
@ -131,19 +131,19 @@
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')"> <el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
<el-input v-model="form.remark" placeholder="备注信息可选" /> <el-input v-model="form.remark" placeholder="备注信息可选" :disabled="isReadOnlyMode" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')"> <el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
<template v-if="isSaveAsMode"> <template v-if="isSaveAsMode">
<el-radio-group v-model="form.versionUpgradeType" @change="onVersionUpgradeTypeChange"> <el-radio-group v-model="form.versionUpgradeType" :disabled="isReadOnlyMode" @change="onVersionUpgradeTypeChange">
<el-radio-button label="minor">升级次版本 ({{ versionOptions.minor }})</el-radio-button> <el-radio-button label="minor">升级次版本 ({{ versionOptions.minor }})</el-radio-button>
<el-radio-button label="major">升级主版本 ({{ versionOptions.major }})</el-radio-button> <el-radio-button label="major">升级主版本 ({{ versionOptions.major }})</el-radio-button>
</el-radio-group> </el-radio-group>
</template> </template>
<template v-else> <template v-else>
<el-input v-model="form.version" placeholder=": V1.0" /> <el-input v-model="form.version" placeholder=": V1.0" :disabled="isReadOnlyMode" />
</template> </template>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -160,6 +160,7 @@
clearable clearable
style="width: 300px; margin-bottom: 10px;" style="width: 300px; margin-bottom: 10px;"
:prefix-icon="Search" :prefix-icon="Search"
:disabled="isReadOnlyMode"
/> />
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300"> <el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
@ -172,12 +173,14 @@
placeholder="请搜索原料" placeholder="请搜索原料"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
style="flex: 1;" style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)" :remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
:loading="selectLoading" :loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`" :loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`" :popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
:disabled="isReadOnlyMode"
default-first-option="true"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)" @visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
> >
<el-option <el-option
@ -192,7 +195,7 @@
</div> </div>
</el-option> </el-option>
</el-select> </el-select>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id"> <el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id && !isReadOnlyMode">
<el-button <el-button
type="primary" type="primary"
link link
@ -207,32 +210,32 @@
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')"> <el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number v-model="row.dosage" :min="0" :precision="0" style="width: 100%" :controls="false" /> <el-input-number v-model="row.dosage" :min="0" :precision="0" style="width: 100%" :controls="false" :disabled="isReadOnlyMode" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')"> <el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" /> <el-input v-model="row.remark" placeholder="备注" :disabled="isReadOnlyMode" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')"> <el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="danger" link @click="removeChild(row.rowKey)">删</el-button> <el-button v-if="!isReadOnlyMode" type="danger" link @click="removeChild(row.rowKey)">删</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id')"> <div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id') && !isReadOnlyMode">
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button> <el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
</div> </div>
</el-form> </el-form>
<template #footer> <template #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button> <el-button v-if="!isReadOnlyMode" type="primary" :loading="saving" @click="submitForm">保存</el-button>
</span> </span>
</template> </template>
</el-dialog> </el-dialog>
@ -280,6 +283,7 @@ const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const isEditMode = ref(false) const isEditMode = ref(false)
const isSaveAsMode = ref(false) const isSaveAsMode = ref(false)
const isReadOnlyMode = ref(false)
let originalVersion = '' let originalVersion = ''
let currentBomNo = '' let currentBomNo = ''
let originalChildren: ChildRow[] = [] let originalChildren: ChildRow[] = []
@ -411,15 +415,18 @@ const handleRemoteSearch = (
type: 'parent' | 'child', type: 'parent' | 'child',
rowKey?: number rowKey?: number
) => { ) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
if (type === 'parent') { if (type === 'parent') {
parentQueryParams.keyword = query parentQueryParams.keyword = safeQuery
parentQueryParams.page = 1 parentQueryParams.page = 1
parentHasMore.value = true parentHasMore.value = true
fetchMaterialOptions('parent') fetchMaterialOptions('parent')
} else if (type === 'child' && rowKey !== undefined) { } else if (type === 'child' && rowKey !== undefined) {
const state = childDropdownStates.value.get(rowKey) const state = childDropdownStates.value.get(rowKey)
if (!state) return if (!state) return
state.queryParams.keyword = query state.queryParams.keyword = safeQuery
state.queryParams.page = 1 state.queryParams.page = 1
state.hasMore = true state.hasMore = true
fetchMaterialOptions('child', rowKey) fetchMaterialOptions('child', rowKey)
@ -433,6 +440,10 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
if (!visible) return if (!visible) return
if (type === 'parent') { if (type === 'parent') {
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。
if (parentQueryParams.keyword || parentOptions.value.length > 0) return
parentQueryParams.page = 1 parentQueryParams.page = 1
parentQueryParams.keyword = '' parentQueryParams.keyword = ''
parentHasMore.value = true parentHasMore.value = true
@ -447,6 +458,8 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
}) })
} }
const state = childDropdownStates.value.get(rowKey)! const state = childDropdownStates.value.get(rowKey)!
// 防御性拦截:竞态条件守卫(同上)
if (state.queryParams.keyword || state.options.length > 0) return
state.queryParams.page = 1 state.queryParams.page = 1
state.queryParams.keyword = '' state.queryParams.keyword = ''
state.hasMore = true state.hasMore = true
@ -674,27 +687,80 @@ const handleCreate = () => {
dialogTitle.value = '新建 BOM' dialogTitle.value = '新建 BOM'
isEditMode.value = false isEditMode.value = false
isSaveAsMode.value = false isSaveAsMode.value = false
isReadOnlyMode.value = false
dialogVisible.value = true dialogVisible.value = true
} }
const handleEdit = async (row: BomItem) => { const handleView = async (row: BomItem) => {
await loadDetail(row.bom_no, row.version) await loadDetail(row.bom_no, row.version)
dialogTitle.value = '编辑 BOM' dialogTitle.value = '查看 BOM'
isEditMode.value = true isEditMode.value = false
isSaveAsMode.value = false isSaveAsMode.value = false
isReadOnlyMode.value = true
dialogVisible.value = true dialogVisible.value = true
} }
const handleSaveAs = async (row: BomItem) => { const handleSaveAs = async (row: BomItem) => {
await loadDetail(row.bom_no, row.version) // 1. 重置 form 基础状态
dialogTitle.value = '另存为新版/变体' resetForm()
isEditMode.value = true
isSaveAsMode.value = true // 2. 获取源 BOM 详情(不通过 loadDetail显式走"深拷贝+ ID"路径)
originalVersion = form.version const res = await getBomDetail(row.bom_no, row.version)
if (res.code !== 200) return
const raw = JSON.parse(JSON.stringify(res.data))
// 3. ★ 核心:显式深拷贝 + 清除所有主键 ID防止后端误判为更新操作
if ('id' in raw) delete raw.id
if ('bom_id' in raw) delete raw.bom_id
if (Array.isArray(raw.children)) {
raw.children.forEach((c: any) => {
if ('id' in c) delete c.id
if ('bom_id' in c) delete c.bom_id
})
}
// 4. 把"已清除 ID 的纯净数据"写入 form保留子件下拉回显 + 父件下拉回显)
form.children = raw.children.map((child: any, idx: number) => ({
rowKey: idx,
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
}))
form.children.forEach((child, idx) => {
initChildDropdownState(idx)
if (child.child_id) {
const state = childDropdownStates.value.get(idx)!
state.options = [{
id: raw.children[idx].child_id,
name: raw.children[idx].child_name || '未知物料',
spec: raw.children[idx].child_spec || ''
}]
state.hasMore = false
}
})
if (raw.parent_id) {
form.parent_id = raw.parent_id
parentOptions.value = [{
id: raw.parent_id,
name: raw.parent_name || '未知产品',
spec: raw.parent_spec || ''
}]
}
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
form.remark = raw.remark || ''
// 5. 设置"另存为"模式特有状态(版本升级单选 + 子件变更检测)
originalVersion = raw.version || ''
currentBomNo = row.bom_no currentBomNo = row.bom_no
originalChildren = JSON.parse(JSON.stringify(form.children)) originalChildren = JSON.parse(JSON.stringify(form.children))
form.versionUpgradeType = 'minor' form.versionUpgradeType = 'minor'
form.version = versionOptions.value.minor form.version = versionOptions.value.minor
// 6. 弹窗状态机:标题"新增 BOM"父件可改启用版本升级单选
dialogTitle.value = '新增 BOM'
isEditMode.value = false
isSaveAsMode.value = true
isReadOnlyMode.value = false
dialogVisible.value = true dialogVisible.value = true
} }
@ -768,6 +834,7 @@ const resetForm = () => {
form.is_enabled = true form.is_enabled = true
form.children = [] form.children = []
isSaveAsMode.value = false isSaveAsMode.value = false
isReadOnlyMode.value = false
originalVersion = '' originalVersion = ''
currentBomNo = '' currentBomNo = ''
childSearchKeyword.value = '' childSearchKeyword.value = ''
@ -864,9 +931,9 @@ onMounted(() => {
} }
if (existingBom) { if (existingBom) {
// ★ 情况 A已经有BOM了直接打开编辑(查看)弹窗 // ★ 情况 A已经有BOM了直接打开只读查看弹窗
ElMessage.success('检测到该物料已有 BOM已自动为您打开'); ElMessage.success('检测到该物料已有 BOM已自动为您打开');
handleEdit(existingBom); handleView(existingBom);
} else { } else {
// ★ 情况 B还没建过BOM打开新建并注入父件 // ★ 情况 B还没建过BOM打开新建并注入父件
handleCreate(); handleCreate();

View File

@ -14,7 +14,7 @@
<el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery"> <el-select v-model="queryParams.searchField" style="width: 90px" @change="handleQuery">
<el-option label="全部" value="all" /> <el-option label="全部" value="all" />
<el-option label="名称" value="name" /> <el-option label="名称" value="name" />
<el-option label="俗名" value="common_name" /> <el-option label="专业名称" value="common_name" />
<el-option label="规格" value="spec" /> <el-option label="规格" value="spec" />
</el-select> </el-select>
</template> </template>
@ -182,7 +182,7 @@
<el-checkbox v-if="hasColPermission('id')" v-model="columns.id.visible" label="ID" /> <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('companyName')" v-model="columns.companyName.visible" label="所属公司" />
<el-checkbox v-if="hasColPermission('name')" v-model="columns.name.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('commonName')" v-model="columns.commonName.visible" label="专业名称" />
<el-checkbox v-if="hasColPermission('category')" v-model="columns.category.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('type')" v-model="columns.type.visible" label="类型" />
<el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" /> <el-checkbox v-if="hasColPermission('spec')" v-model="columns.spec.visible" label="规格型号" />
@ -222,7 +222,7 @@
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" /> <el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip sortable="custom" />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip sortable="custom"> <el-table-column v-if="columns.commonName.visible" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
<template #default="scope"> <template #default="scope">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span> <span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span> <span v-else style="color: #ccc;">-</span>
@ -363,13 +363,23 @@
append-to-body append-to-body
destroy-on-close destroy-on-close
@close="cancel" @close="cancel"
:close-on-click-modal="!isUploading" :close-on-click-modal="false"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"
:show-close="!isUploading" :show-close="!isUploading"
> >
<template #header> <template #header>
<div style="display: flex; align-items: center; justify-content: space-between; padding-right: 20px;"> <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> <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 <el-link
v-if="form.id" v-if="form.id"
type="success" type="success"
@ -380,6 +390,7 @@
<el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM <el-icon style="margin-right: 4px"><Plus /></el-icon>加入或查看BOM
</el-link> </el-link>
</div> </div>
</div>
</template> </template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
@ -390,7 +401,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="俗名" prop="commonName" v-if="hasFieldPermission('commonName')"> <el-form-item label="专业名称" prop="commonName" v-if="hasFieldPermission('commonName')">
<el-input v-model="form.commonName" placeholder="标准名称" /> <el-input v-model="form.commonName" placeholder="标准名称" />
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -409,6 +420,20 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <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')"> <el-form-item label="类别" prop="category" v-if="hasFieldPermission('category')">
<div style="display: flex; width: 100%; align-items: center;"> <div style="display: flex; width: 100%; align-items: center;">
<el-cascader <el-cascader
@ -430,26 +455,6 @@
style="width: 50%;" style="width: 50%;"
/> />
</div> </div>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -457,13 +462,26 @@
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')"> <el-form-item label="计量单位" prop="unit" v-if="hasFieldPermission('unit')">
<el-input v-model="form.unit" placeholder=": , , " /> <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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="可见等级" prop="visibilityLevel"> <el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" /> <el-input v-model="form.spec" placeholder="请输入规格型号" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
@ -494,6 +512,7 @@
> >
<template #prefix><el-icon><Link /></el-icon></template> <template #prefix><el-icon><Link /></el-icon></template>
</el-input> </el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
<el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')"> <el-form-item label="说明书" prop="generalManual" v-if="hasFieldPermission('files')">
@ -547,6 +566,7 @@
> >
<template #prefix><el-icon><Link /></el-icon></template> <template #prefix><el-icon><Link /></el-icon></template>
</el-input> </el-input>
<div style="color: #409EFF; font-size: 12px; margin-top: 4px;">支持将鼠标悬停于虚线框内通过 Ctrl+V 粘贴图片快速上传</div>
</el-form-item> </el-form-item>
<el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')"> <el-form-item label="状态" prop="isEnabled" v-if="hasFieldPermission('isEnabled')">
@ -565,10 +585,10 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"> <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" /> <img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog> </el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false"> <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 <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@ -584,7 +604,7 @@
/> />
<!-- 预警设置弹窗 --> <!-- 预警设置弹窗 -->
<el-dialog v-model="warningDialog.visible" :title="warningDialog.title" width="500px" append-to-body destroy-on-close> <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-form ref="warningFormRef" :model="warningForm" :rules="warningRules" label-width="100px">
<el-alert <el-alert
v-if="warningDialog.selectedCount > 1" v-if="warningDialog.selectedCount > 1"
@ -620,7 +640,7 @@
</el-dialog> </el-dialog>
<!-- 批量质检设置弹窗 --> <!-- 批量质检设置弹窗 -->
<el-dialog v-model="inspectionDialog.visible" title="批量质检设置" width="500px" append-to-body destroy-on-close> <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 <el-alert
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`" :title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
type="info" type="info"
@ -652,7 +672,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'; import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue';
import { Plus, Document, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } from '@element-plus/icons-vue'; import { Plus, Document, DocumentCopy, Refresh, Setting, Rank, Camera, Link, Download, Bell, CircleCheck, Files, ZoomIn, Delete, Picture } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'; import { ElMessage, ElMessageBox, ElLoading } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus'; import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
@ -669,7 +689,8 @@ import {
exportAssetStatistics, exportAssetStatistics,
batchSetWarning, batchSetWarning,
batchSetInspection, batchSetInspection,
markWarningOrdered markWarningOrdered,
getMaterialUnitsAPI
} from '@/api/material_base'; } from '@/api/material_base';
import { uploadFile, deleteFile } from '@/api/common/upload'; import { uploadFile, deleteFile } from '@/api/common/upload';
import { usePasteUpload } from '@/hooks/usePasteUpload'; import { usePasteUpload } from '@/hooks/usePasteUpload';
@ -743,7 +764,7 @@ const fieldOptions = computed(() => {
const allFields = [ const allFields = [
{ value: 'companyName', label: '所属公司', perm: 'material_list:companyName' }, { value: 'companyName', label: '所属公司', perm: 'material_list:companyName' },
{ value: 'name', label: '名称', perm: 'material_list:name' }, { value: 'name', label: '名称', perm: 'material_list:name' },
{ value: 'commonName', label: '俗名', perm: 'material_list:commonName' }, { value: 'commonName', label: '专业名称', perm: 'material_list:commonName' },
{ value: 'category', label: '类别', perm: 'material_list:category' }, { value: 'category', label: '类别', perm: 'material_list:category' },
{ value: 'type', label: '类型', perm: 'material_list:type' }, { value: 'type', label: '类型', perm: 'material_list:type' },
{ value: 'spec', label: '规格型号', perm: 'material_list:spec' }, { value: 'spec', label: '规格型号', perm: 'material_list:spec' },
@ -1003,6 +1024,7 @@ const hasFieldPermission = (field: string) => {
const companyOptions = ref<string[]>([]); const companyOptions = ref<string[]>([]);
const categoryOptions = ref<string[]>([]); const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]); const typeOptions = ref<string[]>([]);
const unitOptions = ref<string[]>([]);
const categoryTreeOptions = ref<CascaderOption[]>([]); const categoryTreeOptions = ref<CascaderOption[]>([]);
// 用于搜索栏级联选择器的数据绑定中转 // 用于搜索栏级联选择器的数据绑定中转
@ -1018,10 +1040,26 @@ const searchCategoryPath = computed({
// 类别级联选择器的 ref // 类别级联选择器的 ref
const categoryCascaderRef = ref<any>(null); const categoryCascaderRef = ref<any>(null);
// 选中类别后自动收起下拉面板 // 选中类别后1) 收起下拉面板2) 自动提取末级 Label 末尾的英文字母填入规格型号
const onCategoryChange = () => { const onCategoryChange = () => {
if (categoryCascaderRef.value) { if (!categoryCascaderRef.value) return;
// 1) 收起下拉
categoryCascaderRef.value.togglePopperVisible(false); categoryCascaderRef.value.togglePopperVisible(false);
// 2) 从末级节点 Label 末尾提取连续的英文字母/数字 (例如 "电子半成品HH" -> "HH",
// "ASD定标实验室Opt9" -> "Opt9"),写入规格型号。
// 仅在 @change 触发时赋一次值,用户可继续手动修改;未匹配到则保持原值
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);
} }
}; };
@ -1127,6 +1165,17 @@ const getOptionsList = () => {
}); });
}; };
// 获取计量单位字典(新增/编辑弹窗下拉历史)
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 querySearchCompany = (queryString: string, cb: any) => {
const results = queryString const results = queryString
? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase())) ? companyOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
@ -1321,6 +1370,23 @@ const handleEdit = (row: MaterialBaseVO) => {
}); });
}; };
// 另存为新项:把当前编辑项的数据复制一份,转为"新增"模式提交
const handleSaveAs = () => {
if (!form.value.id) return; // 防御:新增模式下不该看到此按钮
// 1. 清除主键submitForm 用 form.value.id 判空决定走 add / update 接口
delete form.value.id;
// 2. 切换弹窗标题(项目沿用 dialog.title 命名,无 dialogType / isEdit 变量)
dialog.title = '新增基础信息';
// 3. 清空脏检查基准:让 submitForm 走"完整 payload"分支(新增模式)
originalForm.value = null;
// 4. 提示用户
ElMessage.success('已成功复制当前数据,已切换至【新增】模式。请修改特定信息(如规格型号)后点击确定保存。');
};
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => { const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try { try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' }); const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name, category: '', type: '', company: '' });
@ -1674,8 +1740,21 @@ const customUpload = async (options: any, targetField: 'generalImage' | 'general
if (res.code === 200) { if (res.code === 200) {
const newUrl = res.data.url const newUrl = res.data.url
form.value[targetField].push(newUrl) form.value[targetField].push(newUrl)
// 清理 el-upload 内部 push 的"待上传"占位条目(带 raw 属性的那条 blob URL 占位),
// 否则会与下方手动 push 的新条目重复显示
const targetList = targetField === 'generalImage' ? fileListImage : fileListManual
const staleIndex = targetList.value.findIndex(f => f.raw === file)
if (staleIndex !== -1) targetList.value.splice(staleIndex, 1)
// 手动构造带服务端 URL 的条目并 pushpicture-card 即可正常渲染
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
if (targetField === 'generalImage') {
fileListImage.value.push(fileObj)
} else {
fileListManual.value.push(fileObj)
}
ElMessage.success('上传成功') ElMessage.success('上传成功')
onSuccess(res) // el-upload v-model 自动更新 fileList无需手动 push
} else { } else {
ElMessage.error(res.msg || '上传失败'); ElMessage.error(res.msg || '上传失败');
onError(new Error(res.msg)) onError(new Error(res.msg))
@ -1834,6 +1913,7 @@ onMounted(() => {
getList(); getList();
} }
getOptionsList(); getOptionsList();
fetchUnitList();
// 2. 修复弹窗锁定逻辑 // 2. 修复弹窗锁定逻辑
console.log('--- 准备检测外部跳转参数 ---', route.query); console.log('--- 准备检测外部跳转参数 ---', route.query);

View File

@ -64,7 +64,7 @@
/> />
<!-- ========== 新建/编辑弹窗 ========== --> <!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false"> <el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="formRef" :model="form" label-width="110px"> <el-form ref="formRef" :model="form" label-width="110px">
<el-row :gutter="20"> <el-row :gutter="20">
@ -74,14 +74,14 @@
v-model="materialBaseId" v-model="materialBaseId"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
clearable clearable
placeholder="输入名称或规格搜索..." placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced" :remote-method="handleSearchMaterialDebounced"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
popper-class="long-dropdown" popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials" v-loadmore="handleLoadMoreMaterials"
@visible-change="onMaterialDropdownVisibleChange" @visible-change="onMaterialDropdownVisibleChange"
@ -171,7 +171,7 @@
</el-dialog> </el-dialog>
<!-- ========== 详情弹窗 ========== --> <!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close> <el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item> <el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
@ -215,7 +215,7 @@
</el-dialog> </el-dialog>
<!-- ========== 驳回原因弹窗 ========== --> <!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close> <el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px"> <el-form label-width="80px">
<el-form-item label="申请单号"> <el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span> <span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (query: string) => {
} }
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query searchKeyword.value = safeQuery
searchPage.value = 1 searchPage.value = 1
materialOptions.value = [] materialOptions.value = []
hasNextPage.value = true hasNextPage.value = true
try { try {
const res: any = await searchMaterialPurchase(query, 1) const res: any = await searchMaterialPurchase(safeQuery, 1)
materialOptions.value = res.data || [] materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false hasNextPage.value = res.has_next !== false
} finally { } finally {
@ -431,10 +434,15 @@ const handleLoadMoreMaterials = async () => {
} }
const onMaterialDropdownVisibleChange = (visible: boolean) => { const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) { if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('') handleSearchMaterial('')
} }
}
const onMaterialSelected = (id: number | null) => { const onMaterialSelected = (id: number | null) => {
if (!id) { if (!id) {

View File

@ -48,17 +48,17 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
class="filter-item-select" class="filter-item-select"
clearable clearable
filterable filterable
style="width: 220px;"
@change="fetchData" @change="fetchData"
style="width: 160px;" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
@ -264,7 +264,7 @@
:width="'min(1000px, 95vw)'" :width="'min(1000px, 95vw)'"
top="4vh" top="4vh"
destroy-on-close destroy-on-close
:close-on-click-modal="!isUploading" :close-on-click-modal="false"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"
:show-close="!isUploading" :show-close="!isUploading"
class="stylish-dialog compact-layout" class="stylish-dialog compact-layout"
@ -298,7 +298,7 @@
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
clearable clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced" :remote-method="handleSearchMaterialDebounced"
@ -306,7 +306,7 @@
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
v-loadmore="loadMoreMaterials" v-loadmore="loadMoreMaterials"
popper-class="long-dropdown" popper-class="long-dropdown"
> >
@ -651,8 +651,8 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog> <el-dialog v-model="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"> <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 <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@ -660,7 +660,7 @@
/> />
</el-dialog> </el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -819,6 +819,17 @@ const isUploading = ref(false)
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) const companyOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const queryParams = reactive({ const queryParams = reactive({
page: 1, page: 1,
@ -1125,7 +1136,16 @@ const querySearchCurrency = (queryString: string, cb: any) => {
cb(filtered) cb(filtered)
} }
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') } const handleMaterialDropdownVisible = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => { const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer) if (searchTimer) clearTimeout(searchTimer)
@ -1135,13 +1155,16 @@ const handleSearchMaterialDebounced = (query: string) => {
} }
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query searchKeyword.value = safeQuery
searchPage.value = 1 searchPage.value = 1
materialOptions.value = [] materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query, 1) const res: any = await searchMaterialBase(safeQuery, 1)
if (res.data) { if (res.data) {
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false})) const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults materialOptions.value = apiResults
@ -1383,6 +1406,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions() const res: any = await getFilterOptions()
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies companyOptions.value = res.data.companies
} }
@ -1391,6 +1415,30 @@ const fetchOptions = async () => {
} }
} }
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
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 as any[];
}
});
});
return root;
};
// 加载库位树数据 // 加载库位树数据
const loadWarehouseTree = async () => { const loadWarehouseTree = async () => {
try { try {

View File

@ -47,17 +47,17 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
class="filter-item-select" class="filter-item-select"
clearable clearable
filterable filterable
style="width: 220px;"
@change="fetchData" @change="fetchData"
style="width: 160px;" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
@ -259,7 +259,7 @@
@current-change="fetchData" @current-change="fetchData"
/> />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="!isUploading" :close-on-press-escape="!isUploading" :show-close="!isUploading" class="stylish-dialog compact-layout"> <el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="min(1000px, 95vw)" top="5vh" :close-on-click-modal="false" :close-on-press-escape="!isUploading" :show-close="!isUploading" class="stylish-dialog compact-layout">
<div class="dialog-scroll-container"> <div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form"> <el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
@ -287,7 +287,7 @@
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
clearable clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@ -295,7 +295,7 @@
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
v-loadmore="loadMoreMaterials" v-loadmore="loadMoreMaterials"
popper-class="product-dropdown" popper-class="product-dropdown"
> >
@ -460,11 +460,14 @@
v-model="form.bom_code" v-model="form.bom_code"
filterable filterable
remote remote
reserve-keyword="true"
clearable clearable
placeholder="搜规格/编号" :disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom" :remote-method="handleSearchBom"
:loading="bomSearchLoading" :loading="bomSearchLoading"
@change="handleBomSelect" @change="handleBomSelect"
default-first-option="true"
style="width: 100%" style="width: 100%"
> >
<el-option <el-option
@ -544,10 +547,10 @@
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"> <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" /> <img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog> </el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false"> <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 <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@ -555,7 +558,7 @@
/> />
</el-dialog> </el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -675,6 +678,18 @@ const isUploading = ref(false)
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] }) const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增] const companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false) const advancedFilterVisible = ref(false)
@ -943,9 +958,15 @@ const form = reactive({
// BOM Search Logic // BOM Search Logic
// ------------------------------------ // ------------------------------------
const handleSearchBom = async (query: string) => { const handleSearchBom = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
bomSearchLoading.value = true bomSearchLoading.value = true
try { try {
const res: any = await searchBom(query) const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || [] bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false } } finally { bomSearchLoading.value = false }
} }
@ -1044,7 +1065,17 @@ const rules = {
// ------------------------------------ // ------------------------------------
// Material Search & Population Logic (已修改) // Material Search & Population Logic (已修改)
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') } const handleMaterialDropdownVisible = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => { const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer) if (searchTimer) clearTimeout(searchTimer)
@ -1054,13 +1085,19 @@ const handleSearchMaterialDebounced = (query: string) => {
} }
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query searchKeyword.value = safeQuery
searchPage.value = 1 searchPage.value = 1
materialOptions.value = [] materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query, 1) const res: any = await searchMaterialBase(safeQuery, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false })) const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false hasNextPage.value = res.data?.has_next ?? false
@ -1096,6 +1133,10 @@ const onMaterialSelected = async (val: number) => {
form.material_type = item.type form.material_type = item.type
form.category = item.category form.category = item.category
form.unit = item.unit form.unit = item.unit
// 切换物料时清空已选 BOM防止脏数据
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
// 获取该物料历史入库库位(新增独立接口) // 获取该物料历史入库库位(新增独立接口)
try { try {
@ -1157,6 +1198,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions() const res: any = await getFilterOptions()
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增] companyOptions.value = res.data.companies // [新增]
} }
@ -1165,6 +1207,30 @@ const fetchOptions = async () => {
} }
} }
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
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 as any[];
}
});
});
return root;
};
// 加载库位树数据 // 加载库位树数据
const loadWarehouseTree = async () => { const loadWarehouseTree = async () => {
try { try {

View File

@ -48,17 +48,17 @@
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select <el-cascader
v-model="queryParams.category" v-model="searchCategoryPath"
:options="categoryTreeOptions"
:props="{ checkStrictly: true }"
placeholder="类别" placeholder="类别"
class="filter-item-select" class="filter-item-select"
clearable clearable
filterable filterable
style="width: 220px;"
@change="fetchData" @change="fetchData"
style="width: 160px;" />
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select <el-select
v-model="queryParams.material_type" v-model="queryParams.material_type"
@ -288,7 +288,7 @@
width="min(1000px, 95vw)" width="min(1000px, 95vw)"
top="5vh" top="5vh"
destroy-on-close destroy-on-close
:close-on-click-modal="!isUploading" :close-on-click-modal="false"
:close-on-press-escape="!isUploading" :close-on-press-escape="!isUploading"
:show-close="!isUploading" :show-close="!isUploading"
class="stylish-dialog compact-layout" class="stylish-dialog compact-layout"
@ -322,7 +322,7 @@
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
clearable clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@ -330,7 +330,7 @@
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
v-loadmore="loadMoreMaterials" v-loadmore="loadMoreMaterials"
popper-class="long-dropdown" popper-class="long-dropdown"
> >
@ -525,11 +525,14 @@
v-model="form.bom_code" v-model="form.bom_code"
filterable filterable
remote remote
reserve-keyword="true"
clearable clearable
placeholder="搜规格/编号" :disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom" :remote-method="handleSearchBom"
:loading="bomSearchLoading" :loading="bomSearchLoading"
@change="handleBomSelect" @change="handleBomSelect"
default-first-option="true"
style="width: 100%" style="width: 100%"
> >
<el-option <el-option
@ -603,15 +606,15 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog> <el-dialog v-model="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"> <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 <WebRtcCamera
ref="cameraRef" ref="cameraRef"
@photo-submit="handleCameraConfirm" @photo-submit="handleCameraConfirm"
@cancel="cameraDialogVisible = false" @cancel="cameraDialogVisible = false"
/> />
</el-dialog> </el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body> <el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;"> <div style="text-align: center;">
<div v-loading="printLoading" class="preview-box"> <div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/> <img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -728,6 +731,18 @@ const isUploading = ref(false)
const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] }) const queryParams = reactive({ page: 1, pageSize: 20, keyword: '', searchField: 'all', sku: '', category: '', material_type: '', statuses: ['在库', '借库'], company: '', orderByColumn: '', isAsc: '', advancedFilters: [] })
const categoryOptions = ref<string[]>([]) const categoryOptions = ref<string[]>([])
const categoryTreeOptions = ref<{ value: string; label: string; children?: any[] }[]>([])
// 用于搜索栏级联选择器的数据绑定中转:数组 <-> 以 "/" 拼接的字符串
const searchCategoryPath = computed({
get() {
return queryParams.category ? queryParams.category.split('/') : [];
},
set(val: string[] | null) {
queryParams.category = val && val.length > 0 ? val.join('/') : '';
}
});
const typeOptions = ref<string[]>([]) const typeOptions = ref<string[]>([])
const companyOptions = ref<string[]>([]) // [新增] const companyOptions = ref<string[]>([]) // [新增]
const advancedFilterVisible = ref(false) const advancedFilterVisible = ref(false)
@ -990,9 +1005,15 @@ watch(
// BOM Search Logic // BOM Search Logic
// ------------------------------------ // ------------------------------------
const handleSearchBom = async (query: string) => { const handleSearchBom = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
bomSearchLoading.value = true bomSearchLoading.value = true
try { try {
const res: any = await searchBom(query) const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || [] bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false } } finally { bomSearchLoading.value = false }
} }
@ -1038,7 +1059,17 @@ const handleManagerSelect = (item: any) => {
// ------------------------------------ // ------------------------------------
// Material Search (Matches Buy.vue) // Material Search (Matches Buy.vue)
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') } const handleMaterialDropdownVisible = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => { const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer) if (searchTimer) clearTimeout(searchTimer)
@ -1048,13 +1079,19 @@ const handleSearchMaterialDebounced = (query: string) => {
} }
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true searchLoading.value = true
searchKeyword.value = query searchKeyword.value = safeQuery
searchPage.value = 1 searchPage.value = 1
materialOptions.value = [] materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(query, 1) const res: any = await searchMaterialBase(safeQuery, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false})) const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false hasNextPage.value = res.data?.has_next ?? false
@ -1090,6 +1127,10 @@ const onMaterialSelected = async (val: number) => {
form.category = item.category form.category = item.category
form.unit = item.unit form.unit = item.unit
form.material_type = item.type form.material_type = item.type
// 切换物料时清空已选 BOM防止脏数据
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
checkHistoryAndSetMode(item.id) checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口) // 获取该物料历史入库库位(新增独立接口)
@ -1248,6 +1289,7 @@ const fetchOptions = async () => {
const res: any = await getFilterOptions() const res: any = await getFilterOptions()
if (res.code === 200) { if (res.code === 200) {
categoryOptions.value = res.data.categories categoryOptions.value = res.data.categories
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
typeOptions.value = res.data.types typeOptions.value = res.data.types
companyOptions.value = res.data.companies // [新增] companyOptions.value = res.data.companies // [新增]
} }
@ -1256,6 +1298,30 @@ const fetchOptions = async () => {
} }
} }
// 将 "IRIS/半成品/无人机" 之类的字符串数组构建为级联树
const buildCategoryTree = (categories: string[]) => {
const root: { value: string; label: string; children?: any[] }[] = [];
categories.forEach((cat: string) => {
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 as any[];
}
});
});
return root;
};
// 加载库位树数据 // 加载库位树数据
const loadWarehouseTree = async () => { const loadWarehouseTree = async () => {
try { try {

View File

@ -85,6 +85,8 @@
:title="dialogTitle" :title="dialogTitle"
width="700px" width="700px"
destroy-on-close destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="resetDialog" @close="resetDialog"
> >
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
@ -103,14 +105,14 @@
v-model="form.base_id" v-model="form.base_id"
filterable filterable
remote remote
reserve-keyword reserve-keyword="true"
placeholder="输入名称或规格..." placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial" :remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible" @visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @change="onMaterialSelected"
default-first-option default-first-option="true"
> >
<el-option <el-option
v-for="item in materialOptions" v-for="item in materialOptions"
@ -269,6 +271,7 @@ const perPage = ref(20)
const total = ref(0) const total = ref(0)
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchKeyword = ref('')
const searchLoading = ref(false) const searchLoading = ref(false)
const searchForm = reactive({ const searchForm = reactive({
@ -329,15 +332,22 @@ const handlePageChange = (val: number) => {
} }
const handleMaterialDropdownVisible = (visible: boolean) => { const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) { if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
handleSearchMaterial('') handleSearchMaterial('')
} }
}
const handleSearchMaterial = async (query: string) => { const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchKeyword.value = safeQuery
searchLoading.value = true searchLoading.value = true
try { try {
const res = await searchMaterialBase(query) const res = await searchMaterialBase(safeQuery)
if (res.code === 200) { if (res.code === 200) {
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false })) const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults materialOptions.value = apiResults