- BOM: 移除父件/子件跳转按钮的 isReadOnlyMode 限制, 任何模式均可跳转 - BOM: 补全缺失的5个权限码(sys_element + sys_role_permission) - 采购单导入: supplier_link→detail_link, 新增采购人/邮箱自动填充 - 采购单导入: 实时搜索(300ms防抖)+模糊匹配, 保留搜索按钮 - 采购单导入: 移除原始/详情链接的历史 autocomplete - 基础信息: el-form 增加 formDisabled, 无 material_list:operation 时全局禁用 - /approved-unstocked API 增加 permission_required(inbound_buy) - PurchaseRequest.to_dict() 增加 requester_email
947 lines
35 KiB
Vue
947 lines
35 KiB
Vue
<template>
|
||
<div class="app-container">
|
||
<el-card shadow="always">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span class="title">BOM 配方管理</span>
|
||
<div class="header-right">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索 编号/名称/规格/子件..."
|
||
style="width: 300px; margin-right: 10px;"
|
||
clearable
|
||
@clear="handleSearch"
|
||
@keyup.enter="handleSearch"
|
||
>
|
||
<template #append>
|
||
<el-button :icon="Search" @click="handleSearch" />
|
||
</template>
|
||
</el-input>
|
||
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button>
|
||
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button>
|
||
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<el-skeleton :rows="8" animated v-if="loading && bomGroups.length === 0" />
|
||
<el-empty v-else-if="!loading && bomGroups.length === 0" description="暂无 BOM 数据" />
|
||
<el-collapse v-else v-model="activeCategories" class="bom-category-collapse">
|
||
<el-collapse-item
|
||
v-for="group in bomGroups"
|
||
:key="group.category"
|
||
:title="group.category + ' (' + group.count + ')'"
|
||
:name="group.category"
|
||
>
|
||
<el-table v-if="activeCategories.includes(group.category)" :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="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 />
|
||
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip />
|
||
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag>{{ row.version }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '禁用' }}</el-tag>
|
||
</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="200" align="center" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</el-card>
|
||
|
||
<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">
|
||
<el-col :span="16">
|
||
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
|
||
<!-- ====== 父件搜索 - el-autocomplete ====== -->
|
||
<el-autocomplete
|
||
v-model="parentNameInput"
|
||
:fetch-suggestions="fetchParentSuggestions"
|
||
:value-key="'name'"
|
||
:validate-event="false"
|
||
clearable
|
||
placeholder="请搜索并选择父件"
|
||
:loading="searchLoading"
|
||
:trigger-on-focus="true"
|
||
style="width: 100%"
|
||
:disabled="isReadOnlyMode || isEditMode"
|
||
@select="onParentSelected"
|
||
@clear="onParentClear"
|
||
popper-class="bom-parent-popper"
|
||
>
|
||
<template #default="{ item }">
|
||
<div class="option-row">
|
||
<span class="option-name">{{ item.name }}</span>
|
||
<span class="option-spec">{{ item.spec }}</span>
|
||
</div>
|
||
</template>
|
||
</el-autocomplete>
|
||
<el-link
|
||
v-if="form.parent_id"
|
||
type="primary"
|
||
:underline="false"
|
||
style="margin-left: 12px; font-size: 13px;"
|
||
@click="openParentMaterial"
|
||
>
|
||
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
|
||
</el-link>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<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="isReadOnlyMode || !userStore.hasPermission('bom_manage:operation')" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="16"></el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="10">
|
||
<el-form-item label="BOM 编号" required v-if="hasFormFieldPermission('bom_no')">
|
||
<el-input v-model="form.bom_no" placeholder="选择父件后自动生成" disabled />
|
||
<div style="font-size: 12px; color: #909399; line-height: 1.2; margin-top: 4px;">
|
||
编号预览: <span style="font-weight: bold">{{ form.bom_no || '请选择父件' }}</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
|
||
<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" :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" :disabled="isReadOnlyMode" />
|
||
</template>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">
|
||
子件列表
|
||
<span style="font-weight: normal; font-size: 12px; color: #909399; margin-left: 10px;">(已选 {{ filteredChildren.length }} 条)</span>
|
||
</div>
|
||
|
||
<el-input
|
||
v-model="childSearchKeyword"
|
||
placeholder="请输入子件名称或规格型号搜索"
|
||
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">
|
||
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
|
||
<template #default="{ row }">
|
||
<!-- ====== 子件搜索 - el-autocomplete,行级绑定 ====== -->
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<el-autocomplete
|
||
v-model="row.material_name"
|
||
:fetch-suggestions="(q: string, cb: (results: any[]) => void) => fetchChildSuggestions(row, q, cb)"
|
||
:value-key="'name'"
|
||
:validate-event="false"
|
||
clearable
|
||
placeholder="请搜索原料"
|
||
:loading="searchLoading"
|
||
:trigger-on-focus="true"
|
||
style="flex: 1;"
|
||
:disabled="isReadOnlyMode"
|
||
@select="(item: any) => onChildSelected(row, item)"
|
||
@clear="() => onChildClear(row)"
|
||
>
|
||
<template #default="{ item }">
|
||
<div class="option-row">
|
||
<span class="option-name">{{ item.name }}</span>
|
||
<span class="option-spec">{{ item.spec }}</span>
|
||
</div>
|
||
</template>
|
||
</el-autocomplete>
|
||
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
|
||
<el-button
|
||
type="primary"
|
||
link
|
||
:icon="EditPen"
|
||
@click.stop="openMaterialInNewTab(row.child_id, row.material_spec)"
|
||
style="font-size: 16px; padding: 4px;"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<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" :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="备注" :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 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') && !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">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
|
||
|
||
<el-button
|
||
v-if="!isReadOnlyMode"
|
||
type="warning"
|
||
plain
|
||
:loading="saving"
|
||
@click="saveDraftData"
|
||
>
|
||
暂存草稿
|
||
</el-button>
|
||
|
||
<el-button
|
||
v-if="!isReadOnlyMode"
|
||
type="primary"
|
||
:loading="saving"
|
||
@click="submitForm"
|
||
>
|
||
保 存
|
||
</el-button>
|
||
</span>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, computed, nextTick, watch } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { getBomList, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom'
|
||
import { searchMaterialBase } from '@/api/inbound/buy'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
// ============================================================
|
||
// 类型定义
|
||
// ============================================================
|
||
interface BomItem {
|
||
bom_no: string
|
||
parent_id: number
|
||
parent_name: string
|
||
version: string
|
||
is_enabled: boolean
|
||
child_count: number
|
||
}
|
||
interface MaterialBase {
|
||
id: number
|
||
name: string
|
||
spec: string
|
||
}
|
||
interface ChildRow {
|
||
rowKey: number // 唯一标识,替代 $index 作为 Map key
|
||
child_id: number | null
|
||
material_name: string
|
||
material_spec: string
|
||
dosage: number
|
||
remark: string
|
||
}
|
||
|
||
const originalDraftHash = ref('')
|
||
|
||
const getDraftHash = () => {
|
||
const children = form.children.map(c => ({
|
||
child_id: c.child_id,
|
||
dosage: Number(c.dosage) || 0,
|
||
remark: c.remark || ''
|
||
}))
|
||
return JSON.stringify({
|
||
bom_no: form.bom_no,
|
||
version: form.version,
|
||
parent_id: form.parent_id,
|
||
children
|
||
})
|
||
}
|
||
|
||
const userStore = useUserStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
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[] = []
|
||
let pendingDraftBomNo = ''
|
||
let pendingDraftVersion = ''
|
||
|
||
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
|
||
const activeCategories = ref([]) // 默认全部展开
|
||
const searchKeyword = ref('')
|
||
const childSearchKeyword = ref('')
|
||
|
||
const filteredChildren = computed(() => {
|
||
if (!childSearchKeyword.value) return form.children
|
||
const kw = childSearchKeyword.value.toLowerCase()
|
||
return form.children.filter(child => {
|
||
const name = (child.material_name || '').toLowerCase()
|
||
const spec = (child.material_spec || '').toLowerCase()
|
||
return name.includes(kw) || spec.includes(kw)
|
||
})
|
||
})
|
||
|
||
// 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
|
||
watch(searchKeyword, (val) => {
|
||
// 防抖:延迟 500ms 执行,避免频繁请求
|
||
clearTimeout((window as any)._bomSearchTimer)
|
||
;(window as any)._bomSearchTimer = setTimeout(() => {
|
||
fetchBomList()
|
||
}, 500)
|
||
})
|
||
|
||
// ============================================================
|
||
// Material Search - el-autocomplete
|
||
// ============================================================
|
||
const searchLoading = ref(false)
|
||
const parentNameInput = ref('')
|
||
|
||
const fetchParentSuggestions = (query: string, cb: (results: any[]) => void) => {
|
||
const rawQuery = String(query || '')
|
||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||
searchLoading.value = true
|
||
searchMaterialBase(safeQuery).then((res: any) => {
|
||
const items = res.data?.items || res.data || []
|
||
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
|
||
cb(formatted)
|
||
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
|
||
}
|
||
|
||
const onParentClear = () => {
|
||
form.parent_id = null
|
||
form.bom_no = ''
|
||
}
|
||
|
||
const onParentSelected = (item: any) => {
|
||
form.parent_id = item.id
|
||
parentNameInput.value = item.name || item.material_name || ''
|
||
if (item.spec) {
|
||
form.bom_no = item.spec.split('/')[0].trim()
|
||
} else {
|
||
form.bom_no = ''
|
||
}
|
||
}
|
||
|
||
const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) => void) => {
|
||
const rawQuery = String(query || '')
|
||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||
searchLoading.value = true
|
||
searchMaterialBase(safeQuery).then((res: any) => {
|
||
const items = res.data?.items || res.data || []
|
||
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
|
||
cb(formatted)
|
||
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
|
||
}
|
||
|
||
const onChildClear = (row: any) => {
|
||
row.child_id = null
|
||
row.material_name = ''
|
||
row.material_spec = ''
|
||
row.dosage = 0
|
||
}
|
||
|
||
const onChildSelected = (row: any, item: any) => {
|
||
const existingIndex = form.children.findIndex((child, idx) => idx !== form.children.indexOf(row) && child.child_id === item.id)
|
||
if (existingIndex !== -1) {
|
||
ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
|
||
row.child_id = null
|
||
row.material_name = ''
|
||
row.dosage = 0
|
||
return
|
||
}
|
||
row.child_id = item.id
|
||
row.material_name = item.name
|
||
row.material_spec = item.spec
|
||
row.dosage = 1
|
||
}
|
||
const getChildSpec = (rowKey: number): string => {
|
||
const row = form.children.find(c => c.rowKey === rowKey)
|
||
return row?.material_spec || ''
|
||
}
|
||
|
||
// 在新标签页打开基础信息编辑
|
||
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
|
||
if (!targetId) return ElMessage.warning('请先选择物料')
|
||
const routeUrl = router.resolve({
|
||
path: '/material',
|
||
query: { edit_id: targetId, keyword }
|
||
})
|
||
window.open(routeUrl.href, '_blank')
|
||
}
|
||
|
||
const openParentMaterial = () => {
|
||
if (!form.parent_id) return ElMessage.warning('请先选择父件')
|
||
const keyword = parentNameInput.value || ''
|
||
openMaterialInNewTab(form.parent_id, keyword)
|
||
}
|
||
|
||
// 列与权限Code的映射关系(数据库中的code)
|
||
const permissionMap: Record<string, string> = {
|
||
bom_no: 'bom_manage:bom_no',
|
||
parent_name: 'bom_manage:parent_name',
|
||
parent_spec: 'bom_manage:parent_spec',
|
||
version: 'bom_manage:version',
|
||
status: 'bom_manage:status',
|
||
child_count: 'bom_manage:child_count',
|
||
parent_id: 'bom_manage:parent_id',
|
||
is_enabled: 'bom_manage:status',
|
||
child_id: 'bom_manage:child_id',
|
||
dosage: 'bom_manage:dosage',
|
||
remark: 'bom_manage:remark',
|
||
}
|
||
|
||
const hasColumnPermission = (prop: string) => {
|
||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
|
||
const code = permissionMap[prop]
|
||
return code ? userStore.hasPermission(code) : false
|
||
}
|
||
|
||
const hasFormFieldPermission = (fieldName: string) => {
|
||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
|
||
const code = permissionMap[fieldName]
|
||
return code ? userStore.hasPermission(code) : false
|
||
}
|
||
|
||
const formRef = ref<FormInstance>()
|
||
const form = reactive({
|
||
bom_no: '',
|
||
remark: '',
|
||
parent_id: null as number | null,
|
||
version: 'V1.0',
|
||
versionUpgradeType: 'minor' as 'minor' | 'major',
|
||
is_enabled: true,
|
||
children: [] as ChildRow[]
|
||
})
|
||
|
||
const pureBomNo = computed(() => form.bom_no)
|
||
|
||
const versionOptions = computed(() => {
|
||
const ver = originalVersion || 'V1.0'
|
||
const allItems = bomGroups.value.flatMap((g: any) => g.items)
|
||
const occupiedVersions = new Set(
|
||
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
|
||
)
|
||
const getNextMinor = (baseMajor: number, baseMinor: number): string => {
|
||
let minor = baseMinor + 1
|
||
let candidate = `V${baseMajor}.${minor}`
|
||
while (occupiedVersions.has(candidate)) { minor++; candidate = `V${baseMajor}.${minor}` }
|
||
return candidate
|
||
}
|
||
const getNextMajor = (baseMajor: number): string => {
|
||
let major = baseMajor + 1
|
||
let candidate = `V${major}.0`
|
||
while (occupiedVersions.has(candidate)) { major++; candidate = `V${major}.0` }
|
||
return candidate
|
||
}
|
||
const match = ver.match(/^V(\d+)\.(\d+)$/)
|
||
if (match) {
|
||
return { minor: getNextMinor(parseInt(match[1]), parseInt(match[2])), major: getNextMajor(parseInt(match[1])) }
|
||
}
|
||
return { minor: getNextMinor(1, 0), major: getNextMajor(1) }
|
||
})
|
||
|
||
const onVersionUpgradeTypeChange = (type: 'minor' | 'major') => {
|
||
form.version = versionOptions.value[type]
|
||
}
|
||
|
||
const rules = reactive<FormRules>({
|
||
parentNameInput: [{ required: true, message: '请选择父件', trigger: 'change' }],
|
||
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
|
||
})
|
||
|
||
const dialogTitle = ref('新建 BOM')
|
||
|
||
const handleSearch = () => {
|
||
activeCategories.value = [] // 用户主动搜索时重置折叠状态
|
||
fetchBomList()
|
||
}
|
||
|
||
const fetchBomList = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await getBomList({ keyword: searchKeyword.value })
|
||
if (res.code === 200) {
|
||
bomGroups.value = res.data
|
||
}
|
||
} finally { loading.value = false }
|
||
}
|
||
|
||
const onParentChange = (val: number) => {}
|
||
|
||
const onChildChange = (val: number | null, index: number) => {
|
||
if (val !== null) {
|
||
const existingIndex = form.children.findIndex((child, idx) => idx !== index && child.child_id === val)
|
||
if (existingIndex !== -1) {
|
||
ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
|
||
form.children[index].child_id = null
|
||
form.children[index].dosage = 0
|
||
return
|
||
}
|
||
form.children[index].dosage = 1
|
||
}
|
||
}
|
||
|
||
const getOrGenerateTempBomNo = (): string => {
|
||
let storedBomNo = localStorage.getItem('pending_bom_draft_no')
|
||
if (storedBomNo) {
|
||
return storedBomNo
|
||
}
|
||
const ts = new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14)
|
||
const uid = Math.random().toString(36).slice(2, 10)
|
||
const newBomNo = `DRAFT-TEMP-${ts}-${uid}`
|
||
localStorage.setItem('pending_bom_draft_no', newBomNo)
|
||
return newBomNo
|
||
}
|
||
|
||
const handleCreate = async () => {
|
||
// 1. 全局拦截
|
||
const isIntercepted = await checkAndInterceptGlobalDraft()
|
||
if (isIntercepted) return
|
||
|
||
// 2. 正常的纯新建逻辑
|
||
resetForm()
|
||
const tempBomNo = getOrGenerateTempBomNo()
|
||
form.bom_no = tempBomNo
|
||
originalDraftHash.value = getDraftHash()
|
||
|
||
dialogTitle.value = '新建 BOM'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = false
|
||
isReadOnlyMode.value = false
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const saveDraftData = async () => {
|
||
// 获取当前正在编辑的单号
|
||
const draftBomNo = form.bom_no || getOrGenerateTempBomNo()
|
||
const currentHash = getDraftHash()
|
||
|
||
// 场景 A:防呆拦截。如果数据没有任何改动,直接提示并阻断多余的网络请求
|
||
if (originalDraftHash.value && currentHash === originalDraftHash.value) {
|
||
return ElMessage.warning('草稿数据无变动,无需重复暂存')
|
||
}
|
||
|
||
// 场景 B:执行保存。在"全局唯一草稿"架构下,不再询问,直接实时同步最新状态到数据库
|
||
await executeSaveDraftRequest(draftBomNo)
|
||
}
|
||
|
||
const executeSaveDraftRequest = async (targetBomNo: string) => {
|
||
const draftVersion = form.version || 'V1.0'
|
||
const children = form.children
|
||
.filter(child => child.child_id !== null || child.dosage > 0)
|
||
.map(child => ({
|
||
child_id: child.child_id,
|
||
dosage: child.dosage,
|
||
remark: child.remark || ''
|
||
}))
|
||
|
||
saving.value = true
|
||
try {
|
||
const res = await saveDraft({
|
||
bom_no: targetBomNo,
|
||
version: draftVersion,
|
||
parent_id: form.parent_id,
|
||
children
|
||
})
|
||
if (res.code === 200) {
|
||
ElMessage.success('草稿暂存成功')
|
||
form.bom_no = targetBomNo
|
||
localStorage.setItem('pending_bom_draft_no', targetBomNo)
|
||
localStorage.setItem('pending_bom_draft_version', draftVersion)
|
||
originalDraftHash.value = getDraftHash()
|
||
} else {
|
||
ElMessage.error(res.msg || '暂存失败')
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error('暂存异常')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const restoreDraftToForm = (draftData: any) => {
|
||
form.children = (draftData.children || []).map((child: any, idx: number) => ({
|
||
rowKey: Date.now() + idx,
|
||
child_id: child.child_id,
|
||
material_name: child.child_name || '',
|
||
material_spec: child.child_spec || '',
|
||
dosage: child.dosage,
|
||
remark: child.remark || ''
|
||
}))
|
||
|
||
if (draftData.parent_id) {
|
||
form.parent_id = draftData.parent_id
|
||
parentNameInput.value = draftData.parent_name || ''
|
||
}
|
||
|
||
form.bom_no = draftData.bom_no || ''
|
||
form.version = draftData.version || 'V1.0'
|
||
form.remark = draftData.remark || ''
|
||
pendingDraftBomNo = draftData.bom_no || pendingDraftBomNo
|
||
pendingDraftVersion = draftData.version || pendingDraftVersion
|
||
|
||
originalDraftHash.value = getDraftHash()
|
||
}
|
||
|
||
const checkUserDraft = async (bomNo: string, version: string) => {
|
||
try {
|
||
const res = await getDraftDetail({ bom_no: bomNo, version: version })
|
||
if (res.code === 200 && res.data) {
|
||
const confirm = await ElMessageBox.confirm(
|
||
`检测到未发布的草稿数据,是否恢复继续编辑?`,
|
||
'草稿恢复',
|
||
{ confirmButtonText: '恢复草稿', cancelButtonText: '放弃草稿', type: 'info' }
|
||
).catch(() => 'cancel')
|
||
|
||
if (confirm === 'confirm') {
|
||
return res.data
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 网络异常或查不到均视为无草稿
|
||
}
|
||
return null
|
||
}
|
||
|
||
const checkAndInterceptGlobalDraft = async (): Promise<boolean> => {
|
||
const pendingNo = localStorage.getItem('pending_bom_draft_no')
|
||
const pendingVer = localStorage.getItem('pending_bom_draft_version') || 'V1.0'
|
||
|
||
if (pendingNo) {
|
||
try {
|
||
const res = await getDraftDetail({ bom_no: pendingNo, version: pendingVer })
|
||
if (res.code === 200 && res.data) {
|
||
const action = await ElMessageBox.confirm(
|
||
`系统检测到您有一个未发布的草稿 [${pendingNo}]。\n\n为防止数据混乱,同一时间只允许保留一份草稿。\n• 点击【恢复草稿】继续编辑\n• 点击【放弃旧草稿】彻底销毁它并开启新任务`,
|
||
'全局草稿拦截',
|
||
{
|
||
confirmButtonText: '恢复草稿',
|
||
cancelButtonText: '放弃旧草稿',
|
||
distinguishCancelAndClose: true,
|
||
type: 'warning'
|
||
}
|
||
).catch(action => action)
|
||
|
||
if (action === 'confirm') {
|
||
resetForm()
|
||
restoreDraftToForm(res.data)
|
||
originalDraftHash.value = getDraftHash()
|
||
dialogTitle.value = '继续编辑草稿'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = false
|
||
isReadOnlyMode.value = false
|
||
dialogVisible.value = true
|
||
return true
|
||
} else if (action === 'cancel') {
|
||
localStorage.removeItem('pending_bom_draft_no')
|
||
localStorage.removeItem('pending_bom_draft_version')
|
||
return false
|
||
} else {
|
||
return true
|
||
}
|
||
}
|
||
} catch (e) {
|
||
localStorage.removeItem('pending_bom_draft_no')
|
||
localStorage.removeItem('pending_bom_draft_version')
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
const handleView = async (row: BomItem) => {
|
||
await loadDetail(row.bom_no, row.version)
|
||
dialogTitle.value = '查看 BOM'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = false
|
||
isReadOnlyMode.value = true
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const handleSaveAs = async (row: any) => {
|
||
// 1. 全局拦截
|
||
const isIntercepted = await checkAndInterceptGlobalDraft()
|
||
if (isIntercepted) return
|
||
|
||
// 2. 正常的纯另存为逻辑
|
||
resetForm()
|
||
const res = await getBomDetail(row.bom_no, row.version)
|
||
if (res.code !== 200) return
|
||
const raw = JSON.parse(JSON.stringify(res.data))
|
||
|
||
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
|
||
})
|
||
}
|
||
|
||
form.children = raw.children.map((child: any, idx: number) => ({
|
||
rowKey: idx,
|
||
child_id: child.child_id,
|
||
material_name: child.child_name || '未知物料',
|
||
material_spec: child.child_spec || '',
|
||
dosage: child.dosage,
|
||
remark: child.remark || ''
|
||
}))
|
||
if (raw.parent_id) {
|
||
form.parent_id = raw.parent_id
|
||
parentNameInput.value = raw.parent_name || '未知产品'
|
||
}
|
||
|
||
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
|
||
form.remark = raw.remark || ''
|
||
form.versionUpgradeType = 'minor'
|
||
form.version = versionOptions.value?.minor || 'V1.1'
|
||
|
||
originalVersion = raw.version || ''
|
||
currentBomNo = row.bom_no
|
||
originalChildren = JSON.parse(JSON.stringify(form.children))
|
||
originalDraftHash.value = getDraftHash()
|
||
|
||
dialogTitle.value = '新增 BOM (版本升级)'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = true
|
||
isReadOnlyMode.value = false
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const loadDetail = async (bomNo: string, version: string) => {
|
||
try {
|
||
const res = await getBomDetail(bomNo, version)
|
||
if (res.code === 200) {
|
||
const data = res.data
|
||
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey)
|
||
form.children = data.children.map((child: any, idx: number) => ({
|
||
rowKey: idx,
|
||
child_id: child.child_id,
|
||
material_name: child.child_name || '未知物料',
|
||
material_spec: child.child_spec || '',
|
||
dosage: child.dosage,
|
||
remark: child.remark || ''
|
||
}))
|
||
|
||
if (data.parent_id) {
|
||
form.parent_id = data.parent_id
|
||
parentNameInput.value = data.parent_name || '未知产品'
|
||
}
|
||
|
||
if (data.parent_spec) {
|
||
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
|
||
} else {
|
||
form.bom_no = bomNo.split('/')[0].trim()
|
||
}
|
||
form.remark = data.remark || ''
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
const handleDelete = (row: BomItem) => {
|
||
ElMessageBox.confirm(`确定删除 ${row.bom_no} (${row.version}) 吗?`, '警告', { type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const res = await deleteBom(row.bom_no, row.version)
|
||
if (res.code === 200) { ElMessage.success('删除成功'); fetchBomList() }
|
||
} catch (e) {}
|
||
})
|
||
.catch(() => {})
|
||
}
|
||
|
||
const resetForm = () => {
|
||
form.bom_no = ''
|
||
form.remark = ''
|
||
form.parent_id = null
|
||
form.version = 'V1.0'
|
||
form.versionUpgradeType = 'minor'
|
||
form.is_enabled = true
|
||
form.children = []
|
||
isSaveAsMode.value = false
|
||
isReadOnlyMode.value = false
|
||
originalVersion = ''
|
||
currentBomNo = ''
|
||
childSearchKeyword.value = ''
|
||
parentNameInput.value = ''
|
||
originalDraftHash.value = ''
|
||
pendingDraftBomNo = ''
|
||
pendingDraftVersion = ''
|
||
if (formRef.value) formRef.value.resetFields()
|
||
}
|
||
|
||
const addChild = () => {
|
||
const rowKey = Date.now()
|
||
form.children.push({ rowKey, child_id: null, material_name: '', material_spec: '', dosage: 0, remark: '' })
|
||
}
|
||
|
||
const removeChild = (rowKey: number) => {
|
||
const idx = form.children.findIndex(c => c.rowKey === rowKey)
|
||
if (idx !== -1) form.children.splice(idx, 1)
|
||
}
|
||
|
||
const submitForm = async () => {
|
||
if (!formRef.value) return
|
||
await formRef.value.validate(async (valid) => {
|
||
if (!valid) return
|
||
if (!pureBomNo.value) return ElMessage.warning('BOM编号不能为空')
|
||
if (form.children.length === 0) return ElMessage.warning('请至少添加一个子件')
|
||
|
||
const childIds = form.children.map(c => c.child_id).filter(id => id !== null)
|
||
const uniqueIds = new Set(childIds)
|
||
if (childIds.length !== uniqueIds.size) {
|
||
return ElMessage.warning('子件列表中存在重复物料,请合并用量或删除重复项')
|
||
}
|
||
|
||
if (isSaveAsMode.value && originalChildren.length > 0) {
|
||
const currentSet = new Set(form.children.map(c => `${c.child_id}-${c.dosage}`))
|
||
const originalSet = new Set(originalChildren.map(c => `${c.child_id}-${c.dosage}`))
|
||
const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item))
|
||
if (isIdentical) {
|
||
return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存')
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
bom_no: pureBomNo.value,
|
||
remark: form.remark,
|
||
version: form.version,
|
||
parent_id: form.parent_id,
|
||
is_enabled: form.is_enabled,
|
||
children: form.children
|
||
.filter((c: any) => c.child_id !== null)
|
||
.map((c: any) => ({
|
||
child_id: c.child_id,
|
||
dosage: c.dosage,
|
||
remark: c.remark || ''
|
||
}))
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
const res = await saveBom(payload)
|
||
if (res.code === 200) {
|
||
ElMessage.success('保存成功')
|
||
localStorage.removeItem('pending_bom_draft_no')
|
||
localStorage.removeItem('pending_bom_draft_version')
|
||
originalDraftHash.value = ''
|
||
dialogVisible.value = false
|
||
fetchBomList()
|
||
} else {
|
||
ElMessage.error(res.msg || '保存失败')
|
||
}
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 处理外部跳转自动打开 BOM(带查重保护)
|
||
if (route.query.create_for_id) {
|
||
const parentId = Number(route.query.create_for_id);
|
||
const parentName = (route.query.parent_name as string) || '';
|
||
const parentSpec = (route.query.parent_spec as string) || '';
|
||
|
||
// 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料
|
||
searchKeyword.value = parentName;
|
||
fetchBomList();
|
||
|
||
// 2. 延迟等待基础渲染后进行查重
|
||
setTimeout(() => {
|
||
getBomList({ keyword: parentName }).then((res: any) => {
|
||
const groups = res.data || [];
|
||
let existingBom = null;
|
||
|
||
// ★ 修复点:遍历分组 (groups) 里的 items 来查找正确的 parent_id
|
||
for (const group of groups) {
|
||
if (group.items && group.items.length > 0) {
|
||
const found = group.items.find((b: any) => b.parent_id === parentId);
|
||
if (found) {
|
||
existingBom = found;
|
||
break; // 找到了就跳出循环
|
||
}
|
||
}
|
||
}
|
||
|
||
if (existingBom) {
|
||
// ★ 情况 A:已经有BOM了,直接打开只读查看弹窗
|
||
ElMessage.success('检测到该物料已有 BOM,已自动为您打开');
|
||
handleView(existingBom);
|
||
} else {
|
||
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
||
handleCreate();
|
||
|
||
parentNameInput.value = parentName;
|
||
|
||
// 给表单赋值
|
||
form.parent_id = parentId;
|
||
// 自动带出版本和生成编号
|
||
if (parentSpec) {
|
||
form.bom_no = parentSpec.split('/')[0].trim()
|
||
}
|
||
}
|
||
});
|
||
}, 300);
|
||
} else {
|
||
// 如果不是从其他页面跳转过来的,直接正常加载全部列表
|
||
fetchBomList();
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||
.header-right { display: flex; align-items: center; }
|
||
.title { font-size: 18px; font-weight: bold; }
|
||
.option-row { display: flex; justify-content: space-between; width: 100%; }
|
||
.option-name { font-weight: bold; color: #303133; }
|
||
.option-spec { font-size: 12px; color: #909399; margin-left: 15px; }
|
||
</style>
|