Compare commits
13 Commits
6149662fd8
...
8bb3e58b44
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bb3e58b44 | |||
| cdac915a4b | |||
| 8a2da1ac1e | |||
| 332ae3c4cf | |||
| d51c6f147f | |||
| 2977acbae7 | |||
| 90eed24441 | |||
| 91444034e0 | |||
| 8f901e3f08 | |||
| bac670ef7a | |||
| 1c0c02fd36 | |||
| fffee9d964 | |||
| a3d47f6328 |
@ -98,6 +98,24 @@ def search_base():
|
||||
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)
|
||||
# ==============================================================================
|
||||
|
||||
@ -60,7 +60,8 @@ def search_base():
|
||||
def search_bom():
|
||||
try:
|
||||
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})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
@ -60,7 +60,8 @@ def search_base():
|
||||
def search_bom():
|
||||
try:
|
||||
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})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
@ -528,6 +528,29 @@ class MaterialBaseService:
|
||||
traceback.print_exc()
|
||||
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
|
||||
def create_material(data):
|
||||
"""新增基础信息"""
|
||||
|
||||
@ -382,7 +382,8 @@ class BuyInboundService:
|
||||
|
||||
# 2. 类别独立搜索
|
||||
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. 类型独立搜索
|
||||
if material_type and material_type.strip():
|
||||
|
||||
@ -66,7 +66,7 @@ class ProductInboundService:
|
||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||
|
||||
@staticmethod
|
||||
def search_bom_options(keyword):
|
||||
def search_bom_options(keyword, parent_spec=None):
|
||||
from app.models.bom import BomTable
|
||||
try:
|
||||
query = db.session.query(
|
||||
@ -79,6 +79,9 @@ class ProductInboundService:
|
||||
if hasattr(BomTable, 'is_enabled'):
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
if parent_spec:
|
||||
query = query.filter(MaterialBase.spec_model == parent_spec)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
@ -349,7 +352,8 @@ class ProductInboundService:
|
||||
sku_str = f'%{sku.strip()}%'
|
||||
query = query.filter(StockProduct.sku.ilike(sku_str))
|
||||
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():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
|
||||
@ -71,7 +71,7 @@ class SemiInboundService:
|
||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||
|
||||
@staticmethod
|
||||
def search_bom_options(keyword):
|
||||
def search_bom_options(keyword, parent_spec=None):
|
||||
from app.models.bom import BomTable
|
||||
try:
|
||||
query = db.session.query(
|
||||
@ -84,6 +84,9 @@ class SemiInboundService:
|
||||
if hasattr(BomTable, 'is_enabled'):
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
if parent_spec:
|
||||
query = query.filter(MaterialBase.spec_model == parent_spec)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
@ -439,7 +442,8 @@ class SemiInboundService:
|
||||
sku_str = f'%{sku.strip()}%'
|
||||
query = query.filter(StockSemi.sku.ilike(sku_str))
|
||||
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():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
|
||||
@ -251,7 +251,7 @@ const handleLogout = () => {
|
||||
v-model="profileDialogVisible"
|
||||
title="个人中心"
|
||||
width="480px"
|
||||
:close-on-click-modal="!passwordLoading"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
class="profile-dialog"
|
||||
>
|
||||
@ -331,7 +331,7 @@ const handleLogout = () => {
|
||||
</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-item label="新邮箱" prop="email">
|
||||
<el-input v-model="emailForm.email" placeholder="请输入有效邮箱地址" />
|
||||
|
||||
@ -43,11 +43,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
|
||||
}
|
||||
|
||||
// 搜索BOM
|
||||
export function searchBom(keyword: string) {
|
||||
export function searchBom(keyword: string, parent_spec?: string) {
|
||||
return request({
|
||||
url: '/inbound/product/search-bom',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
params: { keyword, parent_spec }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -45,11 +45,11 @@ export function searchMaterialBase(keyword: string, page: number = 1) {
|
||||
}
|
||||
|
||||
// 5.5 搜索BOM (新增)
|
||||
export function searchBom(keyword: string) {
|
||||
export function searchBom(keyword: string, parent_spec?: string) {
|
||||
return request({
|
||||
url: '/inbound/semi/search-bom',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
params: { keyword, parent_spec }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -86,4 +86,12 @@ export function markWarningOrdered(data: { baseId: number; isOrdered: boolean })
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 获取计量单位字典 (新增/编辑弹窗下拉历史)
|
||||
export function getMaterialUnitsAPI() {
|
||||
return request({
|
||||
url: '/inbound/base/units',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@ -35,7 +35,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
|
||||
@ -51,9 +51,8 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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 }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
@ -64,7 +63,7 @@
|
||||
</div>
|
||||
</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-row :gutter="20">
|
||||
@ -76,13 +75,14 @@
|
||||
placeholder="请搜索并选择父件"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
||||
:loading="selectLoading"
|
||||
style="width: 100%"
|
||||
:disabled="isEditMode"
|
||||
:disabled="isReadOnlyMode || isEditMode"
|
||||
class="beautified-select"
|
||||
popper-class="bom-loadmore-popper parent-popper"
|
||||
default-first-option="true"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||||
@change="onParentChange"
|
||||
>
|
||||
@ -99,7 +99,7 @@
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-link
|
||||
v-if="form.parent_id"
|
||||
v-if="form.parent_id && !isReadOnlyMode"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
style="margin-left: 12px; font-size: 13px;"
|
||||
@ -114,7 +114,7 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<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-col>
|
||||
<el-col :span="16"></el-col>
|
||||
@ -131,19 +131,19 @@
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<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-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
|
||||
<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="major">升级主版本 ({{ versionOptions.major }})</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input v-model="form.version" placeholder="如: V1.0" />
|
||||
<el-input v-model="form.version" placeholder="如: V1.0" :disabled="isReadOnlyMode" />
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@ -160,6 +160,7 @@
|
||||
clearable
|
||||
style="width: 300px; margin-bottom: 10px;"
|
||||
:prefix-icon="Search"
|
||||
:disabled="isReadOnlyMode"
|
||||
/>
|
||||
|
||||
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
|
||||
@ -172,12 +173,14 @@
|
||||
placeholder="请搜索原料"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
style="flex: 1;"
|
||||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
|
||||
:loading="selectLoading"
|
||||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||||
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
|
||||
:disabled="isReadOnlyMode"
|
||||
default-first-option="true"
|
||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
|
||||
>
|
||||
<el-option
|
||||
@ -192,7 +195,7 @@
|
||||
</div>
|
||||
</el-option>
|
||||
</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
|
||||
type="primary"
|
||||
link
|
||||
@ -207,32 +210,32 @@
|
||||
|
||||
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
|
||||
<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>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="备注" />
|
||||
<el-input v-model="row.remark" placeholder="备注" :disabled="isReadOnlyMode" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
|
||||
<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>
|
||||
</el-table-column>
|
||||
</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>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||
<el-button @click="dialogVisible = false">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="!isReadOnlyMode" type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@ -280,6 +283,7 @@ const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const isEditMode = ref(false)
|
||||
const isSaveAsMode = ref(false)
|
||||
const isReadOnlyMode = ref(false)
|
||||
let originalVersion = ''
|
||||
let currentBomNo = ''
|
||||
let originalChildren: ChildRow[] = []
|
||||
@ -411,15 +415,18 @@ const handleRemoteSearch = (
|
||||
type: 'parent' | 'child',
|
||||
rowKey?: number
|
||||
) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
const rawQuery = String(query || '')
|
||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
if (type === 'parent') {
|
||||
parentQueryParams.keyword = query
|
||||
parentQueryParams.keyword = safeQuery
|
||||
parentQueryParams.page = 1
|
||||
parentHasMore.value = true
|
||||
fetchMaterialOptions('parent')
|
||||
} else if (type === 'child' && rowKey !== undefined) {
|
||||
const state = childDropdownStates.value.get(rowKey)
|
||||
if (!state) return
|
||||
state.queryParams.keyword = query
|
||||
state.queryParams.keyword = safeQuery
|
||||
state.queryParams.page = 1
|
||||
state.hasMore = true
|
||||
fetchMaterialOptions('child', rowKey)
|
||||
@ -433,6 +440,10 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
|
||||
if (!visible) return
|
||||
|
||||
if (type === 'parent') {
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。
|
||||
if (parentQueryParams.keyword || parentOptions.value.length > 0) return
|
||||
parentQueryParams.page = 1
|
||||
parentQueryParams.keyword = ''
|
||||
parentHasMore.value = true
|
||||
@ -447,6 +458,8 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
|
||||
})
|
||||
}
|
||||
const state = childDropdownStates.value.get(rowKey)!
|
||||
// 防御性拦截:竞态条件守卫(同上)
|
||||
if (state.queryParams.keyword || state.options.length > 0) return
|
||||
state.queryParams.page = 1
|
||||
state.queryParams.keyword = ''
|
||||
state.hasMore = true
|
||||
@ -674,27 +687,80 @@ const handleCreate = () => {
|
||||
dialogTitle.value = '新建 BOM'
|
||||
isEditMode.value = false
|
||||
isSaveAsMode.value = false
|
||||
isReadOnlyMode.value = false
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (row: BomItem) => {
|
||||
const handleView = async (row: BomItem) => {
|
||||
await loadDetail(row.bom_no, row.version)
|
||||
dialogTitle.value = '编辑 BOM'
|
||||
isEditMode.value = true
|
||||
dialogTitle.value = '查看 BOM'
|
||||
isEditMode.value = false
|
||||
isSaveAsMode.value = false
|
||||
isReadOnlyMode.value = true
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSaveAs = async (row: BomItem) => {
|
||||
await loadDetail(row.bom_no, row.version)
|
||||
dialogTitle.value = '另存为新版/变体'
|
||||
isEditMode.value = true
|
||||
isSaveAsMode.value = true
|
||||
originalVersion = form.version
|
||||
// 1. 重置 form 基础状态
|
||||
resetForm()
|
||||
|
||||
// 2. 获取源 BOM 详情(不通过 loadDetail,显式走"深拷贝+清 ID"路径)
|
||||
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
|
||||
originalChildren = JSON.parse(JSON.stringify(form.children))
|
||||
form.versionUpgradeType = 'minor'
|
||||
form.version = versionOptions.value.minor
|
||||
|
||||
// 6. 弹窗状态机:标题"新增 BOM",父件可改,启用版本升级单选
|
||||
dialogTitle.value = '新增 BOM'
|
||||
isEditMode.value = false
|
||||
isSaveAsMode.value = true
|
||||
isReadOnlyMode.value = false
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@ -768,6 +834,7 @@ const resetForm = () => {
|
||||
form.is_enabled = true
|
||||
form.children = []
|
||||
isSaveAsMode.value = false
|
||||
isReadOnlyMode.value = false
|
||||
originalVersion = ''
|
||||
currentBomNo = ''
|
||||
childSearchKeyword.value = ''
|
||||
@ -864,9 +931,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (existingBom) {
|
||||
// ★ 情况 A:已经有BOM了,直接打开编辑(查看)弹窗
|
||||
// ★ 情况 A:已经有BOM了,直接打开只读查看弹窗
|
||||
ElMessage.success('检测到该物料已有 BOM,已自动为您打开');
|
||||
handleEdit(existingBom);
|
||||
handleView(existingBom);
|
||||
} else {
|
||||
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
||||
handleCreate();
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<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="common_name" />
|
||||
<el-option label="规格" value="spec" />
|
||||
</el-select>
|
||||
</template>
|
||||
@ -182,7 +182,7 @@
|
||||
<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('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="规格型号" />
|
||||
@ -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.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">
|
||||
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
@ -363,22 +363,33 @@
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@close="cancel"
|
||||
:close-on-click-modal="!isUploading"
|
||||
: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>
|
||||
<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 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">
|
||||
@ -390,7 +401,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<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-form-item>
|
||||
</el-col>
|
||||
@ -409,6 +420,20 @@
|
||||
</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
|
||||
@ -430,26 +455,6 @@
|
||||
style="width: 50%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="类型" prop="type" v-if="hasFieldPermission('type')">
|
||||
<el-autocomplete
|
||||
v-model="form.type"
|
||||
:fetch-suggestions="querySearchType"
|
||||
placeholder="可输入或选择"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
|
||||
<el-input v-model="form.spec" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@ -457,13 +462,26 @@
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<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-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="可见等级" prop="visibilityLevel">
|
||||
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
|
||||
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0低-9高)</span>
|
||||
<el-form-item label="规格型号" prop="spec" v-if="hasFieldPermission('spec')">
|
||||
<el-input v-model="form.spec" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@ -494,6 +512,7 @@
|
||||
>
|
||||
<template #prefix><el-icon><Link /></el-icon></template>
|
||||
</el-input>
|
||||
<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')">
|
||||
@ -547,6 +566,7 @@
|
||||
>
|
||||
<template #prefix><el-icon><Link /></el-icon></template>
|
||||
</el-input>
|
||||
<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')">
|
||||
@ -565,10 +585,10 @@
|
||||
</template>
|
||||
</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" />
|
||||
</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
|
||||
ref="cameraRef"
|
||||
@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-alert
|
||||
v-if="warningDialog.selectedCount > 1"
|
||||
@ -620,7 +640,7 @@
|
||||
</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
|
||||
:title="`已选择 ${inspectionDialog.selectedCount} 条物料进行批量质检设置`"
|
||||
type="info"
|
||||
@ -652,7 +672,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 type { FormInstance, FormRules } from 'element-plus';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
@ -669,7 +689,8 @@ import {
|
||||
exportAssetStatistics,
|
||||
batchSetWarning,
|
||||
batchSetInspection,
|
||||
markWarningOrdered
|
||||
markWarningOrdered,
|
||||
getMaterialUnitsAPI
|
||||
} from '@/api/material_base';
|
||||
import { uploadFile, deleteFile } from '@/api/common/upload';
|
||||
import { usePasteUpload } from '@/hooks/usePasteUpload';
|
||||
@ -743,7 +764,7 @@ 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: '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' },
|
||||
@ -1003,6 +1024,7 @@ const hasFieldPermission = (field: string) => {
|
||||
const companyOptions = ref<string[]>([]);
|
||||
const categoryOptions = ref<string[]>([]);
|
||||
const typeOptions = ref<string[]>([]);
|
||||
const unitOptions = ref<string[]>([]);
|
||||
const categoryTreeOptions = ref<CascaderOption[]>([]);
|
||||
|
||||
// 用于搜索栏级联选择器的数据绑定中转
|
||||
@ -1018,10 +1040,26 @@ const searchCategoryPath = computed({
|
||||
// 类别级联选择器的 ref
|
||||
const categoryCascaderRef = ref<any>(null);
|
||||
|
||||
// 选中类别后自动收起下拉面板
|
||||
// 选中类别后:1) 收起下拉面板;2) 自动提取末级 Label 末尾的英文字母填入规格型号
|
||||
const onCategoryChange = () => {
|
||||
if (categoryCascaderRef.value) {
|
||||
categoryCascaderRef.value.togglePopperVisible(false);
|
||||
if (!categoryCascaderRef.value) return;
|
||||
|
||||
// 1) 收起下拉
|
||||
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 results = queryString
|
||||
? 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> => {
|
||||
try {
|
||||
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) {
|
||||
const newUrl = res.data.url
|
||||
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 的条目并 push,picture-card 即可正常渲染
|
||||
const fileObj = { name: newUrl.split('/').pop(), url: getImageUrl(newUrl) }
|
||||
if (targetField === 'generalImage') {
|
||||
fileListImage.value.push(fileObj)
|
||||
} else {
|
||||
fileListManual.value.push(fileObj)
|
||||
}
|
||||
ElMessage.success('上传成功')
|
||||
onSuccess(res) // el-upload v-model 自动更新 fileList,无需手动 push
|
||||
} else {
|
||||
ElMessage.error(res.msg || '上传失败');
|
||||
onError(new Error(res.msg))
|
||||
@ -1834,6 +1913,7 @@ onMounted(() => {
|
||||
getList();
|
||||
}
|
||||
getOptionsList();
|
||||
fetchUnitList();
|
||||
|
||||
// 2. 修复弹窗锁定逻辑
|
||||
console.log('--- 准备检测外部跳转参数 ---', route.query);
|
||||
|
||||
@ -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-row :gutter="20">
|
||||
@ -74,14 +74,14 @@
|
||||
v-model="materialBaseId"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
placeholder="输入名称或规格搜索..."
|
||||
:remote-method="handleSearchMaterialDebounced"
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
default-first-option="true"
|
||||
popper-class="long-dropdown"
|
||||
v-loadmore="handleLoadMoreMaterials"
|
||||
@visible-change="onMaterialDropdownVisibleChange"
|
||||
@ -171,7 +171,7 @@
|
||||
</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-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
@ -215,7 +215,7 @@
|
||||
</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-item label="申请单号">
|
||||
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
||||
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (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
|
||||
searchKeyword.value = query
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
hasNextPage.value = true
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialPurchase(query, 1)
|
||||
const res: any = await searchMaterialPurchase(safeQuery, 1)
|
||||
materialOptions.value = res.data || []
|
||||
hasNextPage.value = res.has_next !== false
|
||||
} finally {
|
||||
@ -431,9 +434,14 @@ const handleLoadMoreMaterials = async () => {
|
||||
}
|
||||
|
||||
const onMaterialDropdownVisibleChange = (visible: boolean) => {
|
||||
if (visible && materialOptions.value.length === 0) {
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
if (!visible) return
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const onMaterialSelected = (id: number | null) => {
|
||||
|
||||
@ -48,17 +48,17 @@
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.category"
|
||||
<el-cascader
|
||||
v-model="searchCategoryPath"
|
||||
:options="categoryTreeOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="类别"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 220px;"
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.material_type"
|
||||
@ -264,7 +264,7 @@
|
||||
:width="'min(1000px, 95vw)'"
|
||||
top="4vh"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="!isUploading"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!isUploading"
|
||||
:show-close="!isUploading"
|
||||
class="stylish-dialog compact-layout"
|
||||
@ -298,7 +298,7 @@
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterialDebounced"
|
||||
@ -306,7 +306,7 @@
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
>
|
||||
@ -651,8 +651,8 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
||||
<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"
|
||||
@ -660,7 +660,7 @@
|
||||
/>
|
||||
</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 v-loading="printLoading" class="preview-box">
|
||||
<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 typeOptions = 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({
|
||||
page: 1,
|
||||
@ -1125,7 +1136,16 @@ const querySearchCurrency = (queryString: string, cb: any) => {
|
||||
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) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
@ -1135,13 +1155,16 @@ const handleSearchMaterialDebounced = (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
|
||||
searchKeyword.value = query
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(query, 1)
|
||||
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||
if (res.data) {
|
||||
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value = apiResults
|
||||
@ -1383,6 +1406,7 @@ const fetchOptions = async () => {
|
||||
const res: any = await getFilterOptions()
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = res.data.categories
|
||||
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
|
||||
typeOptions.value = res.data.types
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@ -47,17 +47,17 @@
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.category"
|
||||
<el-cascader
|
||||
v-model="searchCategoryPath"
|
||||
:options="categoryTreeOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="类别"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 220px;"
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.material_type"
|
||||
@ -259,7 +259,7 @@
|
||||
@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">
|
||||
<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"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@ -295,7 +295,7 @@
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="product-dropdown"
|
||||
>
|
||||
@ -460,11 +460,14 @@
|
||||
v-model="form.bom_code"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
placeholder="搜规格/编号"
|
||||
:disabled="!form.spec_model"
|
||||
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
|
||||
:remote-method="handleSearchBom"
|
||||
:loading="bomSearchLoading"
|
||||
@change="handleBomSelect"
|
||||
default-first-option="true"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
@ -544,10 +547,10 @@
|
||||
</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" />
|
||||
</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
|
||||
ref="cameraRef"
|
||||
@photo-submit="handleCameraConfirm"
|
||||
@ -555,7 +558,7 @@
|
||||
/>
|
||||
</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 v-loading="printLoading" class="preview-box">
|
||||
<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 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 companyOptions = ref<string[]>([]) // [新增]
|
||||
const advancedFilterVisible = ref(false)
|
||||
@ -943,9 +958,15 @@ const form = reactive({
|
||||
// BOM Search Logic
|
||||
// ------------------------------------
|
||||
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
|
||||
try {
|
||||
const res: any = await searchBom(query)
|
||||
const res: any = await searchBom(safeQuery, form.spec_model)
|
||||
bomOptions.value = res.data || []
|
||||
} finally { bomSearchLoading.value = false }
|
||||
}
|
||||
@ -1044,7 +1065,17 @@ const rules = {
|
||||
// ------------------------------------
|
||||
// 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) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
@ -1054,13 +1085,19 @@ const handleSearchMaterialDebounced = (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
|
||||
searchKeyword.value = query
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
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 }))
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.data?.has_next ?? false
|
||||
@ -1096,6 +1133,10 @@ const onMaterialSelected = async (val: number) => {
|
||||
form.material_type = item.type
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
@ -1157,6 +1198,7 @@ const fetchOptions = async () => {
|
||||
const res: any = await getFilterOptions()
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = res.data.categories
|
||||
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
|
||||
typeOptions.value = res.data.types
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@ -48,17 +48,17 @@
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.category"
|
||||
<el-cascader
|
||||
v-model="searchCategoryPath"
|
||||
:options="categoryTreeOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="类别"
|
||||
class="filter-item-select"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 220px;"
|
||||
@change="fetchData"
|
||||
style="width: 160px;"
|
||||
>
|
||||
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="queryParams.material_type"
|
||||
@ -288,7 +288,7 @@
|
||||
width="min(1000px, 95vw)"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="!isUploading"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!isUploading"
|
||||
:show-close="!isUploading"
|
||||
class="stylish-dialog compact-layout"
|
||||
@ -322,7 +322,7 @@
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@ -330,7 +330,7 @@
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
>
|
||||
@ -525,11 +525,14 @@
|
||||
v-model="form.bom_code"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
placeholder="搜规格/编号"
|
||||
:disabled="!form.spec_model"
|
||||
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
|
||||
:remote-method="handleSearchBom"
|
||||
:loading="bomSearchLoading"
|
||||
@change="handleBomSelect"
|
||||
default-first-option="true"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
@ -603,15 +606,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
||||
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
|
||||
<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>
|
||||
<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 v-loading="printLoading" class="preview-box">
|
||||
<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 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 companyOptions = ref<string[]>([]) // [新增]
|
||||
const advancedFilterVisible = ref(false)
|
||||
@ -990,9 +1005,15 @@ watch(
|
||||
// BOM Search Logic
|
||||
// ------------------------------------
|
||||
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
|
||||
try {
|
||||
const res: any = await searchBom(query)
|
||||
const res: any = await searchBom(safeQuery, form.spec_model)
|
||||
bomOptions.value = res.data || []
|
||||
} finally { bomSearchLoading.value = false }
|
||||
}
|
||||
@ -1038,7 +1059,17 @@ const handleManagerSelect = (item: any) => {
|
||||
// ------------------------------------
|
||||
// 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) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
@ -1048,13 +1079,19 @@ const handleSearchMaterialDebounced = (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
|
||||
searchKeyword.value = query
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
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}))
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.data?.has_next ?? false
|
||||
@ -1090,6 +1127,10 @@ const onMaterialSelected = async (val: number) => {
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
checkHistoryAndSetMode(item.id)
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
@ -1248,6 +1289,7 @@ const fetchOptions = async () => {
|
||||
const res: any = await getFilterOptions()
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = res.data.categories
|
||||
categoryTreeOptions.value = buildCategoryTree(res.data.categories || [])
|
||||
typeOptions.value = res.data.types
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@ -85,6 +85,8 @@
|
||||
:title="dialogTitle"
|
||||
width="700px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
@close="resetDialog"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
@ -103,14 +105,14 @@
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
reserve-keyword="true"
|
||||
placeholder="输入名称或规格..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option
|
||||
default-first-option="true"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
@ -269,6 +271,7 @@ const perPage = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const searchLoading = ref(false)
|
||||
|
||||
const searchForm = reactive({
|
||||
@ -329,15 +332,22 @@ const handlePageChange = (val: number) => {
|
||||
}
|
||||
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (visible && materialOptions.value.length === 0) {
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
if (!visible) return
|
||||
// 防御性拦截:竞态条件守卫
|
||||
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword),
|
||||
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
const res = await searchMaterialBase(query)
|
||||
const res = await searchMaterialBase(safeQuery)
|
||||
if (res.code === 200) {
|
||||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||
materialOptions.value = apiResults
|
||||
|
||||
Reference in New Issue
Block a user