spring
2026-04-24 a582fffa7d3f7283e809a7940538ab02e4677948
Merge branch 'dev_NEW_pro' of http://114.132.189.42:9002/r/product-inventory-management into dev_NEW_pro
已添加4个文件
已修改2个文件
1610 ■■■■ 文件已修改
src/api/basicData/common.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productBom.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentPreview/image/index.vue 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/file/index.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/image/index.vue 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/index.vue 869 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/common.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
import request from '@/utils/request'
// é€šç”¨ä¸Šä¼ æŽ¥å£ï¼Œæ”¯æŒ FormData æ‰¹é‡ä¼ æ–‡ä»¶
export function uploadFile(data) {
  return request({
    url: '/common/upload',
    method: 'post',
    data,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  })
}
src/api/productionManagement/productBom.js
@@ -19,6 +19,14 @@
  });
}
// å¤åˆ¶
export function copy(data) {
  return request({
    url: "/technologyBom/copy",
    method: "post",
    data: data,
  });
}
// ä¿®æ”¹
export function update(data) {
  return request({
src/components/AttachmentPreview/image/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,76 @@
<script setup>
const props = defineProps({
  list: {
    type: Array,
    default: () => [],
  },
  thumbSize: {
    type: Number,
    default: 72,
  },
  gap: {
    type: Number,
    default: 10,
  },
})
const normalizedList = computed(() => {
  return (props.list || [])
    .filter((item) => item && item.previewURL)
    .map((item, index) => ({
      id: item.id ?? index,
      name: item.originalFilename || `image-${index + 1}`,
      url: item.previewURL,
    }))
})
const previewUrls = computed(() => normalizedList.value.map((item) => item.url))
</script>
<template>
  <div class="attachment-image-preview">
    <div v-if="!normalizedList.length" class="empty">暂无图片</div>
    <div v-else class="thumbs" :style="{ gap: `${gap}px` }">
      <el-image
        v-for="(item, index) in normalizedList"
        :key="item.id"
        class="thumb"
        :style="{ width: `${thumbSize}px`, height: `${thumbSize}px` }"
        :src="item.url"
        :preview-src-list="previewUrls"
        :initial-index="index"
        fit="cover"
        preview-teleported
      />
    </div>
  </div>
</template>
<style scoped lang="scss">
.attachment-image-preview {
  width: 100%;
}
.empty {
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--el-text-color-secondary);
  border: 1px dashed var(--el-border-color);
  border-radius: 8px;
}
.thumbs {
  display: flex;
  flex-wrap: wrap;
}
.thumb {
  border: 1px solid var(--el-border-color);
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  background: #fff;
}
</style>
src/components/AttachmentUpload/file/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<script setup>
import { UploadFilled } from '@element-plus/icons-vue'
import { uploadFile } from '@/api/basicData/common'
const props = defineProps({
  fileList: {
    type: Array,
    default: () => [],
  },
  index: {
    type: Number,
    default: -1,
  },
  childrenKey: {
    type: String,
    default: 'files',
  },
  limit: {
    type: Number,
    default: 20,
  },
  fileSize: {
    type: Number,
    default: 50,
  },
  fileType: {
    type: Array,
    default: () => [],
  },
  buttonText: {
    type: String,
    default: '单击选择文件',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  uploadFieldName: {
    type: String,
    default: 'files',
  },
})
const emit = defineEmits(['update:fileList', 'change'])
const { proxy } = getCurrentInstance()
const uploadRef = ref()
const uploadQueueTimer = ref(null)
const uploading = ref(false)
const queuedUidSet = ref(new Set())
const innerList = ref([])
function readListFromProps() {
  if (props.index > -1) {
    const row = props.fileList?.[props.index]
    return Array.isArray(row?.[props.childrenKey]) ? row[props.childrenKey] : []
  }
  return Array.isArray(props.fileList) ? props.fileList : []
}
watch(
  () => props.fileList,
  () => {
    innerList.value = [...readListFromProps()]
  },
  { deep: true, immediate: true },
)
const currentList = computed({
  get() {
    return innerList.value
  },
  set(value) {
    const nextList = Array.isArray(value) ? value : []
    innerList.value = nextList
    if (props.index > -1) {
      const nextModelValue = Array.isArray(props.fileList) ? [...props.fileList] : []
      const currentRow = nextModelValue[props.index] || {}
      nextModelValue[props.index] = {
        ...currentRow,
        [props.childrenKey]: nextList,
      }
      emit('update:fileList', nextModelValue)
      emit('change', nextList, nextModelValue)
      return
    }
    emit('update:fileList', nextList)
    emit('change', nextList, nextList)
  },
})
const displayFileList = computed(() => {
  return currentList.value.map((item, index) => ({
    uid: getItemUid(item, index),
    name: getItemName(item, index),
    url: getItemUrl(item),
    status: 'success',
    rawData: item,
  }))
})
const uploadTip = computed(() => {
  if (!props.fileType.length) return `单个文件不超过 ${props.fileSize}MB`
  return `支持 ${props.fileType.join('/')},单个文件不超过 ${props.fileSize}MB`
})
function getItemUid(item, index) {
  if (item?.id !== undefined && item?.id !== null) return `${item.id}`
  return `${getItemName(item, index)}-${getItemUrl(item) || index}`
}
function getItemUrl(item) {
  if (!item) return ''
  if (typeof item === 'string') return item
  return item.url || item.downloadURL || item.previewURL || item.previewUrl || ''
}
function getItemName(item, index = 0) {
  if (!item) return `file-${index + 1}`
  if (typeof item === 'string') return `file-${index + 1}`
  return item.name || item.originalFilename || item.fileName || item.uidFilename || `file-${index + 1}`
}
function normalizeResponseItem(item, index) {
  if (typeof item === 'string') {
    return {
      name: `file-${currentList.value.length + index + 1}`,
      url: item,
    }
  }
  return Object.assign({}, item, {
    url: item.url || item.downloadURL || item.previewURL || item.previewUrl || '',
    name: item.name || item.originalFilename || item.fileName || item.uidFilename || `file-${currentList.value.length + index + 1}`,
  })
}
function extractResponseArray(response) {
  if (Array.isArray(response)) return response
  if (Array.isArray(response?.data)) return response.data
  if (Array.isArray(response?.data?.data)) return response.data.data
  if (Array.isArray(response?.payload)) return response.payload
  if (Array.isArray(response?.payload?.data)) return response.payload.data
  if (Array.isArray(response?.rows)) return response.rows
  if (Array.isArray(response?.result)) return response.result
  return []
}
function validateFile(rawFile) {
  const extension = rawFile.name.includes('.')
    ? rawFile.name.slice(rawFile.name.lastIndexOf('.') + 1).toLowerCase()
    : ''
  if (props.fileType.length) {
    const isValidType = props.fileType.some((type) => {
      const normalizedType = String(type).toLowerCase()
      return rawFile.type.toLowerCase().includes(normalizedType) || extension === normalizedType
    })
    if (!isValidType) {
      proxy.$modal.msgError(`请上传 ${props.fileType.join('/')} æ ¼å¼çš„æ–‡ä»¶`)
      return false
    }
  }
  const isWithinSize = rawFile.size / 1024 / 1024 <= props.fileSize
  if (!isWithinSize) {
    proxy.$modal.msgError(`文件大小不能超过 ${props.fileSize}MB`)
    return false
  }
  return true
}
function scheduleUpload(uploadFiles) {
  clearTimeout(uploadQueueTimer.value)
  uploadQueueTimer.value = setTimeout(() => {
    const readyFiles = uploadFiles.filter((file) => file.raw && !queuedUidSet.value.has(file.uid))
    if (!readyFiles.length) return
    const remainCount = props.limit - currentList.value.length
    if (remainCount <= 0) {
      proxy.$modal.msgError(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶`)
      uploadRef.value?.clearFiles()
      return
    }
    const selectedFiles = readyFiles.slice(0, remainCount)
    if (selectedFiles.length < readyFiles.length) {
      proxy.$modal.msgWarning(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶ï¼Œè¶…出部分已忽略`)
    }
    selectedFiles.forEach((file) => queuedUidSet.value.add(file.uid))
    uploadSelectedFiles(selectedFiles)
  }, 0)
}
async function uploadSelectedFiles(files) {
  const validFiles = files.filter((file) => validateFile(file.raw))
  const invalidFiles = files.filter((file) => !validFiles.includes(file))
  invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
  if (!validFiles.length) {
    uploadRef.value?.clearFiles()
    return
  }
  const formData = new FormData()
  validFiles.forEach((file) => {
    formData.append(props.uploadFieldName, file.raw)
  })
  uploading.value = true
  proxy.$modal.loading('文件上传中,请稍候...')
  try {
    const response = await uploadFile(formData)
    const responseList = extractResponseArray(response).map((item, index) => normalizeResponseItem(item, index))
    if (!responseList.length) {
      proxy.$modal.msgError('上传接口未返回数组数据')
      return
    }
    currentList.value = [...currentList.value, ...responseList]
    proxy.$modal.msgSuccess('上传成功')
  } catch (error) {
    proxy.$modal.msgError(error?.message || '上传失败')
  } finally {
    validFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    uploadRef.value?.clearFiles()
    uploading.value = false
    proxy.$modal.closeLoading()
  }
}
function handleChange(file, uploadFiles) {
  if (props.disabled || uploading.value) return
  scheduleUpload(uploadFiles)
}
function handleRemove(file) {
  const targetUrl = file.url || getItemUrl(file.rawData)
  const nextList = currentList.value.filter((item, index) => {
    const itemUrl = getItemUrl(item)
    const itemName = getItemName(item, index)
    return !(itemUrl === targetUrl && itemName === file.name)
  })
  currentList.value = nextList
}
function handleExceed() {
  proxy.$modal.msgError(`最多上传 ${props.limit} ä¸ªæ–‡ä»¶`)
}
function openFile(file) {
  const fileUrl = file.url || getItemUrl(file.rawData)
  if (!fileUrl) return
  window.open(fileUrl, '_blank')
}
onBeforeUnmount(() => {
  clearTimeout(uploadQueueTimer.value)
})
</script>
<template>
  <div class="attachment-upload-file">
    <el-upload
      ref="uploadRef"
      drag
      :auto-upload="false"
      :multiple="true"
      :show-file-list="true"
      :file-list="displayFileList"
      :disabled="disabled || uploading"
      :limit="limit"
      :on-change="handleChange"
      :on-remove="handleRemove"
      :on-exceed="handleExceed"
      :on-preview="openFile"
    >
      <el-icon class="upload-drag-icon"><UploadFilled /></el-icon>
      <div class="el-upload__text">
        å°†æ–‡ä»¶æ‹–到此处,或 <em>{{ buttonText }}</em>
      </div>
      <div class="upload-tip">{{ uploadTip }}</div>
    </el-upload>
  </div>
</template>
<style scoped lang="scss">
.attachment-upload-file {
  width: 100%;
}
.upload-drag-icon {
  font-size: 40px;
  color: var(--el-text-color-secondary);
}
.upload-tip {
  margin-top: 8px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
}
</style>
src/components/AttachmentUpload/image/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,335 @@
<script setup>
import {Plus} from '@element-plus/icons-vue'
import {uploadFile} from '@/api/basicData/common'
const props = defineProps({
  fileList: {
    type: Array,
    default: () => [],
  },
  index: {
    type: Number,
    default: -1,
  },
  childrenKey: {
    type: String,
    default: 'images',
  },
  limit: {
    type: Number,
    default: 10,
  },
  fileSize: {
    type: Number,
    default: 10,
  },
  fileType: {
    type: Array,
    default: () => ['png', 'jpg', 'jpeg', 'webp'],
  },
  buttonText: {
    type: String,
    default: '上传图片',
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  uploadFieldName: {
    type: String,
    default: 'files',
  },
})
const emit = defineEmits(['update:fileList', 'change'])
const {proxy} = getCurrentInstance()
const uploadRef = ref()
const previewVisible = ref(false)
const previewUrl = ref('')
const uploadQueueTimer = ref(null)
const uploading = ref(false)
const queuedUidSet = ref(new Set())
const currentList = computed({
  get() {
    if (props.index > -1) {
      const row = props.fileList?.[props.index]
      return Array.isArray(row?.[props.childrenKey]) ? row[props.childrenKey] : []
    }
    return Array.isArray(props.fileList) ? props.fileList : []
  },
  set(value) {
    const nextList = Array.isArray(value) ? value : []
    if (props.index > -1) {
      const nextModelValue = Array.isArray(props.fileList) ? [...props.fileList] : []
      const currentRow = nextModelValue[props.index] || {}
      nextModelValue[props.index] = {
        ...currentRow,
        [props.childrenKey]: nextList,
      }
      emit('update:fileList', nextModelValue)
      emit('change', nextList, nextModelValue)
      return
    }
    emit('update:fileList', nextList)
    emit('change', nextList, nextList)
  },
})
const displayFileList = computed(() => {
  return currentList.value.map((item, index) => ({
    uid: getItemUid(item, index),
    name: getItemName(item, index),
    url: getItemUrl(item),
    status: 'success',
    rawData: item,
  }))
})
const uploadTip = computed(() => {
  return `支持 ${props.fileType.join('/')},单张不超过 ${props.fileSize}MB`
})
function getItemUid(item, index) {
  if (item?.id !== undefined && item?.id !== null) return `${item.id}`
  return `${getItemName(item, index)}-${getItemUrl(item) || index}`
}
function getItemUrl(item) {
  if (!item) return ''
  if (typeof item === 'string') return item
  return item.url || item.previewURL || ''
}
function getItemName(item, index = 0) {
  if (!item) return `image-${index + 1}`
  if (typeof item === 'string') return `image-${index + 1}`
  return item.name || item.fileName || item.originalFilename || `image-${index + 1}`
}
function normalizeResponseItem(item, index) {
  if (typeof item === 'string') {
    return {
      name: `image-${currentList.value.length + index + 1}`,
      url: item,
    }
  }
  return Object.assign({}, item, {
    url: item.url || item.previewURL || item.previewUrl || '',
    name: item.name || item.originalFilename || item.fileName || `image-${currentList.value.length + index + 1}`,
  })
}
function extractResponseArray(response) {
  if (Array.isArray(response)) return response
  if (Array.isArray(response?.data)) return response.data
  if (Array.isArray(response?.data?.data)) return response.data.data
  if (Array.isArray(response?.payload)) return response.payload
  if (Array.isArray(response?.payload?.data)) return response.payload.data
  if (Array.isArray(response?.rows)) return response.rows
  if (Array.isArray(response?.result)) return response.result
  return []
}
function validateFile(rawFile) {
  let isValidType = false
  const extension = rawFile.name.includes('.')
      ? rawFile.name.slice(rawFile.name.lastIndexOf('.') + 1).toLowerCase()
      : ''
  if (props.fileType.length) {
    isValidType = props.fileType.some((type) => {
      const normalizedType = String(type).toLowerCase()
      return rawFile.type.toLowerCase().includes(normalizedType) || extension === normalizedType
    })
  } else {
    isValidType = rawFile.type.includes('image')
  }
  if (!isValidType) {
    proxy.$modal.msgError(`请上传 ${props.fileType.join('/')} æ ¼å¼çš„图片`)
    return false
  }
  const isWithinSize = rawFile.size / 1024 / 1024 <= props.fileSize
  if (!isWithinSize) {
    proxy.$modal.msgError(`图片大小不能超过 ${props.fileSize}MB`)
    return false
  }
  return true
}
function scheduleUpload(uploadFiles) {
  clearTimeout(uploadQueueTimer.value)
  uploadQueueTimer.value = setTimeout(() => {
    const readyFiles = uploadFiles.filter((file) => file.raw && !queuedUidSet.value.has(file.uid))
    if (!readyFiles.length) return
    const remainCount = props.limit - currentList.value.length
    if (remainCount <= 0) {
      proxy.$modal.msgError(`最多上传 ${props.limit} å¼ å›¾ç‰‡`)
      uploadRef.value?.clearFiles()
      return
    }
    const selectedFiles = readyFiles.slice(0, remainCount)
    if (selectedFiles.length < readyFiles.length) {
      proxy.$modal.msgWarning(`最多上传 ${props.limit} å¼ å›¾ç‰‡ï¼Œè¶…出部分已忽略`)
    }
    selectedFiles.forEach((file) => queuedUidSet.value.add(file.uid))
    uploadSelectedFiles(selectedFiles)
  }, 0)
}
async function uploadSelectedFiles(files) {
  const validFiles = files.filter((file) => validateFile(file.raw))
  const invalidFiles = files.filter((file) => !validFiles.includes(file))
  invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
  if (!validFiles.length) {
    uploadRef.value?.clearFiles()
    return
  }
  const formData = new FormData()
  validFiles.forEach((file) => {
    formData.append(props.uploadFieldName, file.raw)
  })
  uploading.value = true
  proxy.$modal.loading('图片上传中,请稍候...')
  try {
    const response = await uploadFile(formData)
    const responseList = extractResponseArray(response).map((item, index) => normalizeResponseItem(item, index))
    if (!responseList.length) {
      proxy.$modal.msgError('上传接口未返回数组数据')
      return
    }
    console.log('responseList', responseList)
    currentList.value = [...currentList.value, ...responseList]
    console.log('currentList.value', currentList.value)
    proxy.$modal.msgSuccess('上传成功')
  } catch (error) {
    proxy.$modal.msgError(error?.message || '上传失败')
  } finally {
    validFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    invalidFiles.forEach((file) => queuedUidSet.value.delete(file.uid))
    uploadRef.value?.clearFiles()
    uploading.value = false
    proxy.$modal.closeLoading()
  }
}
function handleChange(file, uploadFiles) {
  if (props.disabled || uploading.value) return
  scheduleUpload(uploadFiles)
}
function handleRemove(file) {
  const targetUrl = file.url || getItemUrl(file.rawData)
  const nextList = currentList.value.filter((item, index) => {
    const itemUrl = getItemUrl(item)
    const itemName = getItemName(item, index)
    return !(itemUrl === targetUrl && itemName === file.name)
  })
  currentList.value = nextList
}
function handlePreview(file) {
  previewUrl.value = file.url || getItemUrl(file.rawData)
  previewVisible.value = true
}
function handleExceed() {
  proxy.$modal.msgError(`最多上传 ${props.limit} å¼ å›¾ç‰‡`)
}
onBeforeUnmount(() => {
  clearTimeout(uploadQueueTimer.value)
})
</script>
<template>
  <div class="attachment-upload-image">
    <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :multiple="true"
        :show-file-list="true"
        :file-list="displayFileList"
        list-type="picture-card"
        accept="image/*"
        :disabled="disabled || uploading"
        :limit="limit"
        :on-change="handleChange"
        :on-remove="handleRemove"
        :on-preview="handlePreview"
        :on-exceed="handleExceed"
    >
      <div class="upload-trigger">
        <el-icon>
          <Plus/>
        </el-icon>
        <span>{{ buttonText }}</span>
      </div>
    </el-upload>
    <div class="upload-tip">
      {{ uploadTip }}
    </div>
    <el-dialog v-model="previewVisible" title="图片预览" width="720px" append-to-body>
      <img class="preview-image" :src="previewUrl" alt="preview"/>
    </el-dialog>
  </div>
</template>
<style scoped lang="scss">
.attachment-upload-image {
  width: 100%;
}
.upload-trigger {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
  line-height: 1.2;
}
.upload-tip {
  margin-top: 8px;
  color: var(--el-text-color-secondary);
  font-size: 12px;
}
.preview-image {
  display: block;
  max-width: 100%;
  margin: 0 auto;
}
:deep(.el-upload-list--picture-card) {
  margin: 0;
}
:deep(.el-upload--picture-card) {
  width: 132px;
  height: 132px;
}
:deep(.el-upload-list--picture-card .el-upload-list__item) {
  width: 132px;
  height: 132px;
}
</style>
src/views/productionManagement/productStructure/index.vue
@@ -1,382 +1,514 @@
<template>
  <div class="app-container">
        <div class="table_list">
            <div style="text-align: right; margin-bottom: 10px;">
                <el-button type="primary" @click="handleAdd">新增</el-button>
                <el-button type="info" plain icon="Upload" @click="handleImport"
                           v-hasPermi="['product:bom:import']">导入</el-button>
                <el-button type="warning" plain icon="Download" @click="handleExport" :disabled="selectedRows.length !== 1"
                           v-hasPermi="['product:bom:export']">导出</el-button>
                <el-button type="danger" plain @click="handleBatchDelete" :disabled="selectedRows.length === 0">删除</el-button>
            </div>
            <PIMTable rowKey="id" :column="tableColumn" :tableData="tableData" :page="page" :isSelection="true"
                      @selection-change="handleSelectionChange" :tableLoading="tableLoading" @pagination="pagination">
                <template #detail="{ row }">
                    <el-button type="primary" text @click="showDetail(row)">{{ row.bomNo }}
                    </el-button>
                </template>
            </PIMTable>
        </div>
    <StructureEdit v-if="showEdit" v-model:show-model="showEdit" :record="currentRow" />
    <div class="table_list">
      <div style="text-align: right; margin-bottom: 10px;">
        <el-button type="primary"
                   @click="handleAdd">新增</el-button>
        <el-button type="info"
                   plain
                   icon="Upload"
                   @click="handleImport"
                   v-hasPermi="['product:bom:import']">导入</el-button>
        <el-button type="warning"
                   plain
                   icon="Download"
                   @click="handleExport"
                   :disabled="selectedRows.length !== 1"
                   v-hasPermi="['product:bom:export']">导出</el-button>
        <el-button type="danger"
                   plain
                   @click="handleBatchDelete"
                   :disabled="selectedRows.length === 0">删除</el-button>
      </div>
      <PIMTable rowKey="id"
                :column="tableColumn"
                :tableData="tableData"
                :page="page"
                :isSelection="true"
                @selection-change="handleSelectionChange"
                :tableLoading="tableLoading"
                @pagination="pagination">
        <template #detail="{ row }">
          <el-button type="primary"
                     text
                     @click="showDetail(row)">{{ row.bomNo }}
          </el-button>
        </template>
      </PIMTable>
    </div>
    <StructureEdit v-if="showEdit"
                   v-model:show-model="showEdit"
                   :record="currentRow" />
    <!-- æ–°å¢ž/编辑弹窗 -->
    <el-dialog v-model="dialogVisible" :title="operationType === 'add' ? '新增BOM' : '编辑BOM'" width="600px"
      @close="closeDialog">
      <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
        <el-form-item label="产品名称" prop="productModelId">
          <el-button type="primary" @click="showProductSelectDialog = true">
    <el-dialog v-model="dialogVisible"
               :title="operationType === 'add' ? '新增BOM' : '编辑BOM'"
               width="600px"
               @close="closeDialog">
      <el-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="120px">
        <el-form-item label="产品名称"
                      prop="productModelId">
          <el-button type="primary"
                     @click="showProductSelectDialog = true">
            {{ form.productName || '选择产品' }}
          </el-button>
        </el-form-item>
        <el-form-item label="版本号" prop="version">
          <el-input v-model="form.version" placeholder="请输入版本号" clearable />
        <el-form-item label="版本号"
                      prop="version">
          <el-input v-model="form.version"
                    placeholder="请输入版本号"
                    clearable />
        </el-form-item>
        <el-form-item label="备注" prop="remark">
          <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" clearable />
        <el-form-item label="备注"
                      prop="remark">
          <el-input v-model="form.remark"
                    type="textarea"
                    :rows="3"
                    placeholder="请输入备注"
                    clearable />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="primary" @click="handleSubmit">确定</el-button>
        <el-button type="primary"
                   @click="handleSubmit">确定</el-button>
        <el-button @click="closeDialog">取消</el-button>
      </template>
    </el-dialog>
    <!-- äº§å“é€‰æ‹©å¼¹çª— -->
    <ProductSelectDialog v-model="showProductSelectDialog" @confirm="handleProductSelect" single />
    <ProductSelectDialog v-model="showProductSelectDialog"
                         @confirm="handleProductSelect"
                         single />
    <!-- BOM导入对话框 -->
    <ImportDialog ref="uploadRef" v-model="upload.open" :title="upload.title" :action="upload.url"
      :headers="upload.headers" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress"
      :on-success="handleFileSuccess" :show-download-template="true" @confirm="submitFileForm"
      @download-template="handleDownloadTemplate" @close="handleImportClose" />
    <ImportDialog ref="uploadRef"
                  v-model="upload.open"
                  :title="upload.title"
                  :action="upload.url"
                  :headers="upload.headers"
                  :disabled="upload.isUploading"
                  :on-progress="handleFileUploadProgress"
                  :on-success="handleFileSuccess"
                  :show-download-template="true"
                  @confirm="submitFileForm"
                  @download-template="handleDownloadTemplate"
                  @close="handleImportClose" />
  </div>
</template>
<script setup>
import { ref, reactive, toRefs, onMounted, getCurrentInstance, defineAsyncComponent } from "vue";
import { getToken } from "@/utils/auth";
import { listPage, add, update, batchDelete, exportBom, downloadTemplate } from "@/api/productionManagement/productBom.js";
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import ProductSelectDialog from "@/views/basicData/product/ProductSelectDialog.vue";
import ImportDialog from "@/components/Dialog/ImportDialog.vue";
  import {
    ref,
    reactive,
    toRefs,
    onMounted,
    getCurrentInstance,
    defineAsyncComponent,
  } from "vue";
  import { getToken } from "@/utils/auth";
  import {
    listPage,
    add,
    copy,
    update,
    batchDelete,
    exportBom,
    downloadTemplate,
  } from "@/api/productionManagement/productBom.js";
  import { useRouter } from "vue-router";
  import { ElMessageBox } from "element-plus";
  import ProductSelectDialog from "@/views/basicData/product/ProductSelectDialog.vue";
  import ImportDialog from "@/components/Dialog/ImportDialog.vue";
const router = useRouter()
const { proxy } = getCurrentInstance()
const StructureEdit = defineAsyncComponent(() => import('@/views/productionManagement/productStructure/StructureEdit.vue'))
  const router = useRouter();
  const { proxy } = getCurrentInstance();
  const StructureEdit = defineAsyncComponent(() =>
    import("@/views/productionManagement/productStructure/StructureEdit.vue")
  );
const tableColumn = ref([
  {
    label: "BOM编号",
    prop: "bomNo",
    dataType: 'slot',
    slot: "detail",
    minWidth: 140
  },
  {
    label: "产品名称",
    prop: "productName",
  const tableColumn = ref([
    {
      label: "BOM编号",
      prop: "bomNo",
      dataType: "slot",
      slot: "detail",
      minWidth: 140,
    },
    {
      label: "产品名称",
      prop: "productName",
    minWidth: 160
  },
  {
    label: "规格型号",
    prop: "productModelName",
    minWidth: 140
  },
  {
    label: "版本号",
    prop: "version",
    width: 100
  },
  {
    label: "备注",
    prop: "remark",
    minWidth: 160
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 150,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          handleEdit(row)
        }
      },
      {
        name: "删除",
        type: "danger",
        link: true,
        clickFun: (row) => {
          handleDelete(row)
        }
      }
    ]
  }
]);
      minWidth: 160,
    },
    {
      label: "规格型号",
      prop: "productModelName",
      minWidth: 140,
    },
    {
      label: "版本号",
      prop: "version",
      width: 100,
    },
    {
      label: "备注",
      prop: "remark",
      minWidth: 160,
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 250,
      operation: [
        {
          name: "复制",
          type: "text",
          clickFun: row => {
            handleCopy(row);
          },
        },
        {
          name: "编辑",
          type: "text",
          clickFun: row => {
            handleEdit(row);
          },
        },
        {
          name: "删除",
          type: "danger",
          link: true,
          clickFun: row => {
            handleDelete(row);
          },
        },
      ],
    },
  ]);
const tableData = ref([]);
const tableLoading = ref(false);
const showEdit = ref(false);
const selectedRows = ref([]);
const currentRow = ref({});
const dialogVisible = ref(false);
const operationType = ref('add'); // add | edit
const formRef = ref(null);
const showProductSelectDialog = ref(false);
  const tableData = ref([]);
  const tableLoading = ref(false);
  const showEdit = ref(false);
  const selectedRows = ref([]);
  const currentRow = ref({});
  const dialogVisible = ref(false);
  const operationType = ref("add"); // add | edit
  const formRef = ref(null);
  const showProductSelectDialog = ref(false);
//  BOM导入参数
const upload = reactive({
  // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚(BOM导入)
  open: false,
  // å¼¹å‡ºå±‚标题(BOM导入)
  title: "",
  // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
  isUploading: false,
  // è®¾ç½®ä¸Šä¼ çš„请求头部
  headers: { Authorization: "Bearer " + getToken() },
  // ä¸Šä¼ çš„地址
  url: import.meta.env.VITE_APP_BASE_API + "/technologyBom/uploadBom"
});
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const data = reactive({
  form: {
    id: undefined,
    productName: "",
    productModelName: "",
    productModelId: "",
    remark: "",
    version: ""
  },
  rules: {
    productModelId: [{ required: true, message: "请选择产品", trigger: "change" }],
    version: [{ required: true, message: "请输入版本号", trigger: "blur" }]
  }
});
const { form, rules } = toRefs(data);
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// åˆ†é¡µ
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
// æŸ¥è¯¢åˆ—表
const getList = () => {
  tableLoading.value = true;
  listPage({
    current: page.current,
    size: page.size,
  })
    .then((res) => {
      const records = res?.data?.records || [];
      tableData.value = records;
      page.total = res?.data?.total || 0;
    })
    .catch((err) => {
      console.error("获取列表失败:", err);
    })
    .finally(() => {
      tableLoading.value = false;
    });
};
// æ–°å¢ž
const handleAdd = () => {
  operationType.value = 'add';
  Object.assign(form.value, {
    id: undefined,
    productName: "",
    productModelName: "",
    productModelId: "",
    remark: "",
    version: ""
  //  BOM导入参数
  const upload = reactive({
    // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚(BOM导入)
    open: false,
    // å¼¹å‡ºå±‚标题(BOM导入)
    title: "",
    // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
    isUploading: false,
    // è®¾ç½®ä¸Šä¼ çš„请求头部
    headers: { Authorization: "Bearer " + getToken() },
    // ä¸Šä¼ çš„地址
    url: import.meta.env.VITE_APP_BASE_API + "/technologyBom/uploadBom",
  });
  dialogVisible.value = true;
};
// ç¼–辑
const handleEdit = (row) => {
  operationType.value = 'edit';
  Object.assign(form.value, {
    id: row.id,
    productName: row.productName || "",
    productModelName: row.productModelName || "",
    productModelId: row.productModelId || "",
    remark: row.remark || "",
    version: row.version || ""
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  dialogVisible.value = true;
};
// åˆ é™¤ï¼ˆå•条)
const handleDelete = (row) => {
  ElMessageBox.confirm('确认删除该BOM?', '提示', {
    confirmButtonText: '确认',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => {
      batchDelete([row.id])
        .then(() => {
          proxy.$modal.msgSuccess('删除成功');
          getList();
        })
        .catch(() => {
          proxy.$modal.msgError('删除失败');
        });
    })
    .catch(() => { });
};
// æ‰¹é‡åˆ é™¤
const handleBatchDelete = () => {
  if (!selectedRows.value.length) {
    proxy.$modal.msgWarning('请选择数据');
    return;
  }
  const ids = selectedRows.value.map(item => item.id);
  ElMessageBox.confirm('选中的内容将被删除,是否确认删除?', '删除提示', {
    confirmButtonText: '确认',
    cancelButtonText: '取消',
    type: 'warning'
  })
    .then(() => {
      batchDelete(ids)
        .then(() => {
          proxy.$modal.msgSuccess('删除成功');
          getList();
        })
        .catch(() => {
          proxy.$modal.msgError('删除失败');
        });
    })
    .catch(() => { });
};
// äº§å“é€‰æ‹©
const handleProductSelect = (products) => {
  if (products && products.length > 0) {
    const product = products[0];
    form.value.productModelId = product.id;
    form.value.productName = product.productName;
    form.value.productModelName = product.model;
  }
  showProductSelectDialog.value = false;
};
// æäº¤è¡¨å•
const handleSubmit = () => {
  formRef.value.validate((valid) => {
    if (valid) {
      const payload = { ...form.value };
      if (operationType.value === 'add') {
        add(payload)
          .then(() => {
            proxy.$modal.msgSuccess('新增成功');
            closeDialog();
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError('新增失败');
          });
      } else {
        update(payload)
          .then(() => {
            proxy.$modal.msgSuccess('修改成功');
            closeDialog();
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError('修改失败');
          });
      }
    }
  const data = reactive({
    form: {
      id: undefined,
      productName: "",
      productModelName: "",
      productModelId: "",
      remark: "",
      version: "",
    },
    rules: {
      productModelId: [
        { required: true, message: "请选择产品", trigger: "change" },
      ],
      version: [{ required: true, message: "请输入版本号", trigger: "blur" }],
    },
  });
};
// å…³é—­å¼¹çª—
const closeDialog = () => {
  dialogVisible.value = false;
  formRef.value?.resetFields();
};
  const { form, rules } = toRefs(data);
//  å¯¼å…¥æŒ‰é’®æ“ä½œ
const handleImport = () => {
  upload.title = "BOM导入";
  upload.open = true;
};
  // è¡¨æ ¼é€‰æ‹©æ•°æ®
  const handleSelectionChange = selection => {
    selectedRows.value = selection;
  };
// å…³é—­å¯¼å…¥å¯¹è¯æ¡†æ—¶æ¸…除文件
const handleImportClose = () => {
  proxy.$refs["uploadRef"].clearFiles();
};
//  æ–‡ä»¶ä¸Šä¼ ä¸­å¤„理
const handleFileUploadProgress = (event, file, fileList) => {
  upload.isUploading = true;
};
//  æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç†
const handleFileSuccess = (response, file, fileList) => {
  upload.open = false;
  upload.isUploading = false;
  proxy.$refs["uploadRef"].clearFiles();
  if (response.code === 200) {
    proxy.$modal.msgSuccess(response.msg || "导入成功");
  // åˆ†é¡µ
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  } else {
    proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true });
  }
};
  };
// æäº¤ä¸Šä¼ æ–‡ä»¶
const submitFileForm = () => {
  proxy.$refs["uploadRef"].submit();
};
  // æŸ¥è¯¢åˆ—表
  const getList = () => {
    tableLoading.value = true;
    listPage({
      current: page.current,
      size: page.size,
    })
      .then(res => {
        const records = res?.data?.records || [];
        tableData.value = records;
        page.total = res?.data?.total || 0;
      })
      .catch(err => {
        console.error("获取列表失败:", err);
      })
      .finally(() => {
        tableLoading.value = false;
      });
  };
//  å¯¼å‡ºæŒ‰é’®æ“ä½œ
const handleExport = () => {
  if (selectedRows.value.length !== 1) {
    proxy.$modal.msgWarning("请选择一条数据进行导出");
    return;
  }
  // æ–°å¢ž
  const handleAdd = () => {
    operationType.value = "add";
    Object.assign(form.value, {
      id: undefined,
      productName: "",
      productModelName: "",
      productModelId: "",
      remark: "",
      version: "",
    });
    dialogVisible.value = true;
  };
  const handleCopy = row => {
    // handleAdd(row);
    ElMessageBox.confirm("确认复制该BOM?", "提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        copy({
          id: row.id,
        })
          .then(() => {
            proxy.$modal.msgSuccess("复制成功");
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError("复制失败");
          });
      })
      .catch(() => {});
  };
  const bomId = selectedRows.value[0].id;
  const fileName = `BOM_${selectedRows.value[0].bomNo || bomId}.xlsx`;
  // ç¼–辑
  const handleEdit = row => {
    operationType.value = "edit";
    Object.assign(form.value, {
      id: row.id,
      productName: row.productName || "",
      productModelName: row.productModelName || "",
      productModelId: row.productModelId || "",
      remark: row.remark || "",
      version: row.version || "",
    });
    dialogVisible.value = true;
  };
  exportBom(bomId).then(res => {
    // è¿”回的数据是否为空
    if (!res) {
      proxy.$modal.msgError("导出失败,返回数据为空");
  // åˆ é™¤ï¼ˆå•条)
  const handleDelete = row => {
    ElMessageBox.confirm("确认删除该BOM?", "提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        batchDelete([row.id])
          .then(() => {
            proxy.$modal.msgSuccess("删除成功");
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError("删除失败");
          });
      })
      .catch(() => {});
  };
  // æ‰¹é‡åˆ é™¤
  const handleBatchDelete = () => {
    if (!selectedRows.value.length) {
      proxy.$modal.msgWarning("请选择数据");
      return;
    }
    const ids = selectedRows.value.map(item => item.id);
    ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "删除提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        batchDelete(ids)
          .then(() => {
            proxy.$modal.msgSuccess("删除成功");
            getList();
          })
          .catch(() => {
            proxy.$modal.msgError("删除失败");
          });
      })
      .catch(() => {});
  };
  // äº§å“é€‰æ‹©
  const handleProductSelect = products => {
    if (products && products.length > 0) {
      const product = products[0];
      form.value.productModelId = product.id;
      form.value.productName = product.productName;
      form.value.productModelName = product.model;
    }
    showProductSelectDialog.value = false;
  };
  // æäº¤è¡¨å•
  const handleSubmit = () => {
    formRef.value.validate(valid => {
      if (valid) {
        const payload = { ...form.value };
        if (operationType.value === "add") {
          add(payload)
            .then(() => {
              proxy.$modal.msgSuccess("新增成功");
              closeDialog();
              getList();
            })
            .catch(() => {
              proxy.$modal.msgError("新增失败");
            });
        } else {
          update(payload)
            .then(() => {
              proxy.$modal.msgSuccess("修改成功");
              closeDialog();
              getList();
            })
            .catch(() => {
              proxy.$modal.msgError("修改失败");
            });
        }
      }
    });
  };
  // å…³é—­å¼¹çª—
  const closeDialog = () => {
    dialogVisible.value = false;
    formRef.value?.resetFields();
  };
  //  å¯¼å…¥æŒ‰é’®æ“ä½œ
  const handleImport = () => {
    upload.title = "BOM导入";
    upload.open = true;
  };
  // å…³é—­å¯¼å…¥å¯¹è¯æ¡†æ—¶æ¸…除文件
  const handleImportClose = () => {
    proxy.$refs["uploadRef"].clearFiles();
  };
  //  æ–‡ä»¶ä¸Šä¼ ä¸­å¤„理
  const handleFileUploadProgress = (event, file, fileList) => {
    upload.isUploading = true;
  };
  //  æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç†
  const handleFileSuccess = (response, file, fileList) => {
    upload.open = false;
    upload.isUploading = false;
    proxy.$refs["uploadRef"].clearFiles();
    if (response.code === 200) {
      proxy.$modal.msgSuccess(response.msg || "导入成功");
      getList();
    } else {
      proxy.$alert(
        "<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" +
          response.msg +
          "</div>",
        "导入结果",
        { dangerouslyUseHTMLString: true }
      );
    }
  };
  // æäº¤ä¸Šä¼ æ–‡ä»¶
  const submitFileForm = () => {
    proxy.$refs["uploadRef"].submit();
  };
  //  å¯¼å‡ºæŒ‰é’®æ“ä½œ
  const handleExport = () => {
    if (selectedRows.value.length !== 1) {
      proxy.$modal.msgWarning("请选择一条数据进行导出");
      return;
    }
    const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
    const downloadElement = document.createElement('a');
    const bomId = selectedRows.value[0].id;
    const fileName = `BOM_${selectedRows.value[0].bomNo || bomId}.xlsx`;
    exportBom(bomId)
      .then(res => {
        // è¿”回的数据是否为空
        if (!res) {
          proxy.$modal.msgError("导出失败,返回数据为空");
          return;
        }
        const blob = new Blob([res], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        const downloadElement = document.createElement("a");
        const href = window.URL.createObjectURL(blob);
        downloadElement.style.display = "none";
        downloadElement.href = href;
        downloadElement.download = fileName;
        document.body.appendChild(downloadElement);
        downloadElement.click();
        document.body.removeChild(downloadElement);
        window.URL.revokeObjectURL(href);
        proxy.$modal.msgSuccess("导出成功");
      })
      .catch(err => {
        console.error("导出异常:", err);
        proxy.$modal.msgError("系统异常,导出失败");
      });
  };
  //  ä¸‹è½½æ¨¡æ¿
  const handleDownloadTemplate = async () => {
    const res = await downloadTemplate();
    // è¿”回的数据是否为空
    if (!res) {
      proxy.$modal.msgError("下载失败,返回数据为空");
      return;
    }
    const blob = new Blob([res], {
      type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    });
    const downloadElement = document.createElement("a");
    const href = window.URL.createObjectURL(blob);
    downloadElement.style.display = 'none';
    downloadElement.href = href;
    downloadElement.download = fileName;
    downloadElement.download = "BOM模板.xlsx";
    document.body.appendChild(downloadElement);
    downloadElement.click();
@@ -384,52 +516,23 @@
    document.body.removeChild(downloadElement);
    window.URL.revokeObjectURL(href);
    proxy.$modal.msgSuccess("导出成功");
  }).catch(err => {
    console.error("导出异常:", err);
    proxy.$modal.msgError("系统异常,导出失败");
    proxy.$modal.msgSuccess("下载成功");
  };
  // æŸ¥çœ‹è¯¦æƒ…
  const showDetail = row => {
    router.push({
      path: "/productionManagement/productStructureDetail",
      query: {
        id: row.id,
        bomNo: row.bomNo || "",
        productName: row.productName || "",
        productModelName: row.productModelName || "",
      },
    });
  };
  onMounted(() => {
    getList();
  });
};
//  ä¸‹è½½æ¨¡æ¿
const handleDownloadTemplate = async () => {
  const res = await downloadTemplate();
  // è¿”回的数据是否为空
  if (!res) {
    proxy.$modal.msgError("下载失败,返回数据为空");
    return;
  }
  const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  const downloadElement = document.createElement('a');
  const href = window.URL.createObjectURL(blob);
  downloadElement.href = href;
  downloadElement.download = "BOM模板.xlsx";
  document.body.appendChild(downloadElement);
  downloadElement.click();
  document.body.removeChild(downloadElement);
  window.URL.revokeObjectURL(href);
  proxy.$modal.msgSuccess("下载成功");
};
// æŸ¥çœ‹è¯¦æƒ…
const showDetail = (row) => {
  router.push({
    path: '/productionManagement/productStructureDetail',
    query: {
      id: row.id,
      bomNo: row.bomNo || '',
      productName: row.productName || '',
      productModelName: row.productModelName || ''
    }
  });
};
onMounted(() => {
  getList();
});
</script>