zhou zhou
4 天以前 1d95b134d85c3c60cf0e72739888c9741a0bb1ee
rsf-design/src/views/basic-info/wh-mat/index.vue
@@ -5,7 +5,9 @@
        <ElCard class="wh-mat-page__sidebar-card">
          <div class="mb-3 flex items-center justify-between gap-3">
            <div>
              <div class="text-base font-medium text-[var(--art-text-primary)]">{{ t('pages.basicInfo.whMat.title') }}</div>
              <div class="text-base font-medium text-[var(--art-text-primary)]">{{
                t('pages.basicInfo.whMat.title')
              }}</div>
              <div class="text-xs text-[var(--art-text-secondary)]">
                {{ selectedGroupLabel }}
              </div>
@@ -28,21 +30,26 @@
            <div v-if="groupTreeLoading" class="py-6">
              <ElSkeleton :rows="10" animated />
            </div>
            <ElEmpty v-else-if="!groupTreeData.length" :description="t('pages.basicInfo.whMat.messages.emptyGroups')" />
            <ElEmpty
              v-else-if="!groupTreeData.length"
              :description="t('pages.basicInfo.whMat.messages.emptyGroups')"
            />
            <ElTree
              v-else
              :data="groupTreeData"
              :props="treeProps"
              node-key="id"
              highlight-current
              default-expand-all
              :default-expanded-keys="defaultExpandedGroupKeys"
              :current-node-key="selectedGroupId"
              @node-click="handleGroupNodeClick"
            >
              <template #default="{ data }">
                <div class="flex items-center gap-2">
                  <span class="font-medium">{{ data.name || t('common.placeholder.empty') }}</span>
                  <span class="text-xs text-[var(--art-text-secondary)]">{{ data.code || t('common.placeholder.empty') }}</span>
                  <span class="text-xs text-[var(--art-text-secondary)]">{{
                    data.code || t('common.placeholder.empty')
                  }}</span>
                </div>
              </template>
            </ElTree>
@@ -60,23 +67,155 @@
        />
        <ElCard class="art-table-card">
          <ArtTableHeader :loading="loading" v-model:columns="columnChecks" @refresh="loadMatnrList" />
          <ArtTableHeader
            :loading="loading"
            v-model:columns="columnChecks"
            @refresh="handleRefresh"
          >
            <template #left>
              <ElSpace wrap>
                <ElButton v-auth="'add'" @click="handleShowDialog('add')" v-ripple>
                  {{ t('pages.basicInfo.whMat.actions.add') }}
                </ElButton>
                <ElButton
                  v-if="showBatchActionButtons"
                  v-auth="'update'"
                  :disabled="selectedRows.length === 0"
                  @click="openBatchGroupDialog"
                  v-ripple
                >
                  {{ t('pages.basicInfo.whMat.actions.batchGroup') }}
                </ElButton>
                <ElButton
                  v-if="showBatchActionButtons"
                  v-auth="'update'"
                  :disabled="selectedRows.length === 0"
                  @click="openBatchDialog('validWarn')"
                  v-ripple
                >
                  {{ t('pages.basicInfo.whMat.actions.batchWarn') }}
                </ElButton>
                <ElButton
                  v-if="showBatchActionButtons"
                  v-auth="'update'"
                  :disabled="selectedRows.length === 0"
                  @click="openBatchDialog('flagCheck')"
                  v-ripple
                >
                  {{ t('pages.basicInfo.whMat.actions.batchFlagCheck') }}
                </ElButton>
                <ElButton
                  v-if="showBatchActionButtons"
                  v-auth="'update'"
                  :disabled="selectedRows.length === 0"
                  @click="openBatchDialog('status')"
                  v-ripple
                >
                  {{ t('pages.basicInfo.whMat.actions.batchStatus') }}
                </ElButton>
                <ElButton
                  v-if="showBatchActionButtons"
                  v-auth="'update'"
                  :disabled="selectedRows.length === 0"
                  @click="openBatchDialog('stockLevel')"
                  v-ripple
                >
                  {{ t('pages.basicInfo.whMat.actions.batchStockLevel') }}
                </ElButton>
                <ElButton
                  v-if="showBatchActionButtons"
                  v-auth="'update'"
                  :disabled="selectedRows.length === 0"
                  @click="openBindLocDialog"
                  v-ripple
                >
                  {{ t('pages.basicInfo.whMat.actions.bindLoc') }}
                </ElButton>
                <ElButton
                  v-auth="'delete'"
                  type="danger"
                  :disabled="selectedRows.length === 0"
                  @click="handleBatchDelete"
                  v-ripple
                >
                  {{ t('common.actions.batchDelete') }}
                </ElButton>
                <div v-auth="'update'">
                  <ElUpload
                    :auto-upload="false"
                    :show-file-list="false"
                    accept=".xlsx,.xls"
                    @change="handleImportFileChange"
                  >
                    <ElButton :loading="importing" v-ripple>
                      {{ t('pages.basicInfo.whMat.actions.import') }}
                    </ElButton>
                  </ElUpload>
                </div>
                <ElButton :loading="templateDownloading" @click="handleDownloadTemplate" v-ripple>
                  {{ t('pages.basicInfo.whMat.actions.downloadTemplate') }}
                </ElButton>
                <ListExportPrint
                  class="inline-flex"
                  :preview-visible="previewVisible"
                  @update:previewVisible="handlePreviewVisibleChange"
                  :report-title="reportTitle"
                  :selected-rows="selectedRows"
                  :query-params="reportQueryParams"
                  :columns="columns"
                  :preview-rows="previewRows"
                  :preview-meta="resolvedPreviewMeta"
                  :total="pagination.total"
                  :disabled="loading"
                  @export="handleExport"
                  @print="handlePrint"
                />
              </ElSpace>
            </template>
          </ArtTableHeader>
          <ArtTable
            :loading="loading"
            :data="tableData"
            :columns="columns"
            :pagination="pagination"
            row-key="id"
            @selection-change="handleSelectionChange"
            @pagination:size-change="handleSizeChange"
            @pagination:current-change="handleCurrentChange"
          >
            <template #action="{ row }">
              <ArtButtonTable icon="ri:eye-line" @click="openDetailDrawer(row)" />
            </template>
          </ArtTable>
          />
        </ElCard>
      </div>
    </div>
    <WhMatDialog
      v-model:visible="dialogVisible"
      :dialog-type="dialogType"
      :material-data="currentMaterialData"
      :group-options="groupOptions"
      :serial-rule-options="serialRuleOptions"
      @submit="handleDialogSubmit"
    />
    <WhMatBatchDialog
      v-model:visible="batchDialogVisible"
      :action-type="batchDialogType"
      @submit="handleBatchDialogSubmit"
    />
    <WhMatBatchGroupDialog
      v-model:visible="batchGroupDialogVisible"
      :group-options="groupOptions"
      @submit="handleBatchGroupSubmit"
    />
    <WhMatBindLocDialog
      v-model:visible="bindLocDialogVisible"
      :area-mat-options="areaMatOptions"
      :area-options="areaOptions"
      :loc-options="locOptions"
      @submit="handleBindLocSubmit"
    />
    <WhMatDetailDrawer
      v-model:visible="detailDrawerVisible"
@@ -87,38 +226,97 @@
</template>
<script setup>
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, reactive, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { useI18n } from 'vue-i18n'
  import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import { useAuth } from '@/hooks/core/useAuth'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { useUserStore } from '@/store/modules/user'
  import { fetchSerialRulePage } from '@/api/system-manage'
  import { fetchWarehouseAreasList } from '@/api/warehouse-areas'
  import { fetchLocPage } from '@/api/loc'
  import { fetchLocAreaMatList } from '@/api/loc-area-mat'
  import { fetchBindLocAreaMatRelaByMatnr } from '@/api/loc-area-mat-rela'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchMatnrDetail, fetchMatnrGroupTree, fetchMatnrPage } from '@/api/wh-mat'
  import { useCrudPage } from '@/views/system/common/useCrudPage'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import {
    fetchBatchUpdateMatnr,
    fetchBindMatnrGroup,
    fetchDeleteMatnr,
    fetchDownloadMatnrTemplate,
    fetchEnabledFields,
    fetchExportMatnrReport,
    fetchGetMatnrMany,
    fetchImportMatnr,
    fetchMatnrDetail,
    fetchMatnrGroupTree,
    fetchMatnrPage,
    fetchSaveMatnr,
    fetchUpdateMatnr
  } from '@/api/wh-mat'
  import WhMatBatchDialog from './modules/wh-mat-batch-dialog.vue'
  import WhMatBatchGroupDialog from './modules/wh-mat-batch-group-dialog.vue'
  import WhMatBindLocDialog from './modules/wh-mat-bind-loc-dialog.vue'
  import WhMatDialog from './modules/wh-mat-dialog.vue'
  import WhMatDetailDrawer from './modules/wh-mat-detail-drawer.vue'
  import { createWhMatTableColumns } from './whMatTable.columns'
  import {
    WH_MAT_REPORT_STYLE,
    WH_MAT_REPORT_TITLE,
    buildMatnrGroupTreeQueryParams,
    buildMatnrPageQueryParams,
    buildWhMatDialogModel,
    buildWhMatPrintRows,
    buildWhMatReportMeta,
    buildWhMatSavePayload,
    createWhMatSearchState,
    getWhMatDynamicFieldKey,
    getWhMatFlagLabelManageOptions,
    getWhMatFlagCheckOptions,
    getWhMatStockLevelOptions,
    getWhMatStatusOptions,
    getWhMatTreeNodeLabel,
    normalizeMatnrDetail,
    normalizeWhMatEnabledFields,
    normalizeMatnrGroupTreeRows,
    normalizeMatnrRow
    normalizeMatnrRow,
    resolveWhMatGroupOptions,
    resolveWhMatSerialRuleOptions
  } from './whMatPage.helpers'
  defineOptions({ name: 'WhMat' })
  const { t } = useI18n()
  const { t } = useI18n()
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
  const showBatchActionButtons = false
  const loading = ref(false)
  const groupTreeLoading = ref(false)
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const batchDialogVisible = ref(false)
  const batchGroupDialogVisible = ref(false)
  const bindLocDialogVisible = ref(false)
  const bindLocOptionsLoading = ref(false)
  const importing = ref(false)
  const templateDownloading = ref(false)
  const tableData = ref([])
  const groupTreeData = ref([])
  const detailData = ref({})
  const enabledFields = ref([])
  const serialRuleOptions = ref([])
  const areaOptions = ref([])
  const areaMatOptions = ref([])
  const locOptions = ref([])
  const selectedGroupId = ref(null)
  const groupSearch = ref('')
  const batchDialogType = ref('status')
  const searchForm = ref(createWhMatSearchState())
  let handleDeleteAction = null
  const pagination = reactive({
    current: 1,
@@ -130,6 +328,18 @@
    label: 'name',
    children: 'children'
  }
  const reportTitle = WH_MAT_REPORT_TITLE
  const groupOptions = computed(() => resolveWhMatGroupOptions(groupTreeData.value))
  const defaultExpandedGroupKeys = computed(() => collectExpandedGroupKeys(groupTreeData.value))
  const reportQueryParams = computed(() =>
    buildMatnrPageQueryParams({
      ...searchForm.value,
      groupId: searchForm.value?.groupId || selectedGroupId.value,
      current: 1,
      pageSize: pagination.size
    })
  )
  const searchItems = computed(() => [
    {
@@ -160,12 +370,65 @@
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.groupId'),
      key: 'groupId',
      type: 'treeselect',
      props: {
        data: groupOptions.value,
        props: {
          label: 'displayLabel',
          value: 'value',
          children: 'children'
        },
        checkStrictly: true,
        defaultExpandAll: true,
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.groupIdPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.platCode'),
      key: 'platCode',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.platCodePlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.spec'),
      key: 'spec',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.specPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.model'),
      key: 'model',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.modelPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.color'),
      key: 'color',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.colorPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.size'),
      key: 'size',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.sizePlaceholder')
      }
    },
    {
@@ -176,13 +439,214 @@
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.barcodePlaceholder')
      }
    }
    },
    {
      label: t('pages.basicInfo.whMat.search.unit'),
      key: 'unit',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.unitPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.purUnit'),
      key: 'purUnit',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.purUnitPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.stockUnit'),
      key: 'stockUnit',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.stockUnitPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.describle'),
      key: 'describle',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.describlePlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.rglarId'),
      key: 'rglarId',
      type: 'select',
      props: {
        clearable: true,
        filterable: true,
        placeholder: t('pages.basicInfo.whMat.search.rglarIdPlaceholder'),
        options: serialRuleOptions.value
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.weight'),
      key: 'weight',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.weightPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.nromNum'),
      key: 'nromNum',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.nromNumPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.stockLevel'),
      key: 'stockLevel',
      type: 'select',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.stockLevelPlaceholder'),
        options: getWhMatStockLevelOptions()
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.flagLabelMange'),
      key: 'flagLabelMange',
      type: 'select',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.flagLabelMangePlaceholder'),
        options: getWhMatFlagLabelManageOptions(t)
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.safeQty'),
      key: 'safeQty',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.safeQtyPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.minQty'),
      key: 'minQty',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.minQtyPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.maxQty'),
      key: 'maxQty',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.maxQtyPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.stagn'),
      key: 'stagn',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.stagnPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.valid'),
      key: 'valid',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.validPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.validWarn'),
      key: 'validWarn',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        valueOnClear: null,
        placeholder: t('pages.basicInfo.whMat.search.validWarnPlaceholder')
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.flagCheck'),
      key: 'flagCheck',
      type: 'select',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.flagCheckPlaceholder'),
        options: getWhMatFlagCheckOptions(t)
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.status'),
      key: 'status',
      type: 'select',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.statusPlaceholder'),
        options: getWhMatStatusOptions(t)
      }
    },
    {
      label: t('pages.basicInfo.whMat.search.memo'),
      key: 'memo',
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.memoPlaceholder')
      }
    },
    ...enabledFields.value.map((field) => ({
      label: field.fieldsAlise,
      key: getWhMatDynamicFieldKey(field.fields),
      type: 'input',
      props: {
        clearable: true,
        placeholder: t('pages.basicInfo.whMat.search.dynamicPlaceholder', {
          field: field.fieldsAlise
        })
      }
    }))
  ])
  const { columnChecks, columns } = useTableColumns(() =>
  const { columnChecks, columns, resetColumns } = useTableColumns(() =>
    createWhMatTableColumns({
      t,
      handleViewDetail: openDetailDrawer
      enabledFields: enabledFields.value,
      handleViewDetail: openDetailDrawer,
      handleEdit: hasAuth('update') ? openEditDialog : null,
      handleDelete: hasAuth('delete') ? (row) => handleDeleteAction?.(row) : null,
      handlePrint: (row) => handlePrint({ ids: [row.id] }),
      canEdit: hasAuth('update'),
      canDelete: hasAuth('delete'),
      t
    })
  )
@@ -208,6 +672,46 @@
      }
    }
    return null
  }
  function collectExpandedGroupKeys(nodes, depth = 1, maxExpandedDepth = 1) {
    if (!Array.isArray(nodes) || depth > maxExpandedDepth) {
      return []
    }
    return nodes.flatMap((node) => {
      const currentId = node?.id !== undefined && node?.id !== null ? [node.id] : []
      return [
        ...currentId,
        ...collectExpandedGroupKeys(node?.children || [], depth + 1, maxExpandedDepth)
      ]
    })
  }
  function normalizeOptionText(value) {
    return String(value ?? '').trim()
  }
  function buildOption(value, label, extra = {}) {
    return {
      value,
      label,
      ...extra
    }
  }
  async function loadEnabledFieldDefinitions() {
    const fields = await guardRequestWithMessage(fetchEnabledFields(), [], {
      timeoutMessage: t('pages.basicInfo.whMat.messages.enabledFieldsTimeout')
    })
    enabledFields.value = normalizeWhMatEnabledFields(fields)
    enabledFields.value.forEach((field) => {
      const dynamicKey = getWhMatDynamicFieldKey(field.fields)
      if (searchForm.value[dynamicKey] === undefined) {
        searchForm.value[dynamicKey] = ''
      }
    })
    resetColumns()
  }
  function updatePaginationState(target, response, fallbackCurrent, fallbackSize) {
@@ -237,6 +741,99 @@
    }
  }
  async function loadSerialRuleOptions() {
    try {
      const response = await guardRequestWithMessage(
        fetchSerialRulePage({ current: 1, pageSize: 200 }),
        { records: [] },
        { timeoutMessage: t('pages.basicInfo.whMat.messages.serialRuleTimeout') }
      )
      serialRuleOptions.value = resolveWhMatSerialRuleOptions(
        defaultResponseAdapter(response).records
      )
    } catch (error) {
      serialRuleOptions.value = []
      ElMessage.error(error?.message || t('pages.basicInfo.whMat.messages.serialRuleLoadFailed'))
    }
  }
  async function ensureBindLocOptionsLoaded(force = false) {
    if (
      !force &&
      areaOptions.value.length &&
      areaMatOptions.value.length &&
      locOptions.value.length
    ) {
      return
    }
    if (bindLocOptionsLoading.value) {
      return
    }
    bindLocOptionsLoading.value = true
    try {
      const [areasResponse, areaMatResponse, locResponse] = await Promise.all([
        guardRequestWithMessage(fetchWarehouseAreasList(), [], {
          timeoutMessage: t('pages.basicInfo.whMat.messages.bindLocTimeout')
        }),
        guardRequestWithMessage(fetchLocAreaMatList(), [], {
          timeoutMessage: t('pages.basicInfo.whMat.messages.bindLocTimeout')
        }),
        guardRequestWithMessage(
          fetchLocPage({ current: 1, pageSize: 1000 }),
          { records: [] },
          {
            timeoutMessage: t('pages.basicInfo.whMat.messages.bindLocTimeout')
          }
        )
      ])
      areaOptions.value = defaultResponseAdapter(areasResponse)
        .records.map((item) =>
          buildOption(
            Number(item.id),
            [normalizeOptionText(item.name), normalizeOptionText(item.code)]
              .filter(Boolean)
              .join(' · ') || t('common.placeholder.empty'),
            {
              areaId: Number(item.id),
              warehouseId: item.warehouseId !== undefined ? Number(item.warehouseId) : void 0
            }
          )
        )
        .filter((item) => Number.isFinite(item.value))
      areaMatOptions.value = defaultResponseAdapter(areaMatResponse)
        .records.map((item) =>
          buildOption(
            Number(item.id),
            [normalizeOptionText(item.code), normalizeOptionText(item.depict || item.name)]
              .filter(Boolean)
              .join(' · ') || t('common.placeholder.empty'),
            {
              areaMatId: Number(item.id),
              areaId: item.areaId !== undefined ? Number(item.areaId) : void 0
            }
          )
        )
        .filter((item) => Number.isFinite(item.value))
      locOptions.value = defaultResponseAdapter(locResponse)
        .records.map((item) =>
          buildOption(
            Number(item.id),
            normalizeOptionText(item.code) || t('common.placeholder.empty'),
            {
              areaId: item.areaId !== undefined ? Number(item.areaId) : void 0
            }
          )
        )
        .filter((item) => Number.isFinite(item.value))
    } finally {
      bindLocOptionsLoading.value = false
    }
  }
  async function loadMatnrList() {
    loading.value = true
    try {
@@ -244,7 +841,7 @@
        fetchMatnrPage(
          buildMatnrPageQueryParams({
            ...searchForm.value,
            groupId: selectedGroupId.value,
            groupId: searchForm.value?.groupId || selectedGroupId.value,
            current: pagination.current,
            pageSize: pagination.size
          })
@@ -258,7 +855,7 @@
        { timeoutMessage: t('pages.basicInfo.whMat.messages.listTimeout') }
      )
      tableData.value = Array.isArray(response?.records)
        ? response.records.map((record) => normalizeMatnrRow(record, t))
        ? response.records.map((record) => normalizeMatnrRow(record, t, enabledFields.value))
        : []
      updatePaginationState(pagination, response, pagination.current, pagination.size)
    } catch (error) {
@@ -269,16 +866,21 @@
    }
  }
  async function loadMatnrDetail(id) {
    return await guardRequestWithMessage(
      fetchMatnrDetail(id),
      {},
      {
        timeoutMessage: t('pages.basicInfo.whMat.messages.detailTimeout')
      }
    )
  }
  async function openDetailDrawer(row) {
    detailDrawerVisible.value = true
    detailLoading.value = true
    try {
      detailData.value = normalizeMatnrDetail(
        await guardRequestWithMessage(fetchMatnrDetail(row.id), {}, {
          timeoutMessage: t('pages.basicInfo.whMat.messages.detailTimeout')
        }),
        t
      )
      detailData.value = normalizeMatnrDetail(await loadMatnrDetail(row.id), t, enabledFields.value)
    } catch (error) {
      detailDrawerVisible.value = false
      detailData.value = {}
@@ -288,21 +890,236 @@
    }
  }
  async function openEditDialog(row) {
    try {
      const detail = await loadMatnrDetail(row.id)
      showDialog('edit', detail)
    } catch (error) {
      ElMessage.error(error?.message || t('pages.basicInfo.whMat.messages.detailLoadFailed'))
    }
  }
  const {
    dialogVisible,
    dialogType,
    currentRecord: currentMaterialData,
    selectedRows,
    handleSelectionChange,
    showDialog,
    handleDialogSubmit,
    handleDelete,
    handleBatchDelete
  } = useCrudPage({
    createEmptyModel: () =>
      buildWhMatDialogModel({ groupId: selectedGroupId.value || searchForm.value?.groupId || '' }),
    buildEditModel: (record) => buildWhMatDialogModel(record),
    buildSavePayload: (formData) => buildWhMatSavePayload(formData),
    saveRequest: fetchSaveMatnr,
    updateRequest: fetchUpdateMatnr,
    deleteRequest: fetchDeleteMatnr,
    entityName: t('pages.basicInfo.whMat.entity'),
    resolveRecordLabel: (record) => record?.name || record?.code || record?.id,
    refreshCreate: loadMatnrList,
    refreshUpdate: loadMatnrList,
    refreshRemove: loadMatnrList
  })
  handleDeleteAction = handleDelete
  const getSelectedIds = () =>
    selectedRows.value.map((item) => Number(item?.id)).filter((id) => Number.isFinite(id))
  const ensureSelectedRows = () => {
    const ids = getSelectedIds()
    if (!ids.length) {
      ElMessage.warning(t('pages.basicInfo.whMat.messages.selectAtLeastOne'))
      return []
    }
    return ids
  }
  function openBatchDialog(type) {
    if (!ensureSelectedRows().length) {
      return
    }
    batchDialogType.value = type
    batchDialogVisible.value = true
  }
  function openBatchGroupDialog() {
    if (!ensureSelectedRows().length) {
      return
    }
    batchGroupDialogVisible.value = true
  }
  async function openBindLocDialog() {
    if (!ensureSelectedRows().length) {
      return
    }
    try {
      await ensureBindLocOptionsLoaded()
      bindLocDialogVisible.value = true
    } catch (error) {
      ElMessage.error(error?.message || t('pages.basicInfo.whMat.messages.bindLocLoadFailed'))
    }
  }
  async function handleBatchDialogSubmit(formData) {
    const ids = ensureSelectedRows()
    if (!ids.length) {
      batchDialogVisible.value = false
      return
    }
    try {
      await fetchBatchUpdateMatnr({
        ids,
        matnr: formData
      })
      ElMessage.success(t('crud.messages.updateSuccess'))
      batchDialogVisible.value = false
      selectedRows.value = []
      await loadMatnrList()
    } catch (error) {
      ElMessage.error(error?.message || t('crud.messages.submitFailed'))
    }
  }
  async function handleBatchGroupSubmit(formData) {
    const ids = ensureSelectedRows()
    if (!ids.length) {
      batchGroupDialogVisible.value = false
      return
    }
    try {
      await fetchBindMatnrGroup({
        ids,
        groupId: formData.groupId
      })
      ElMessage.success(t('crud.messages.updateSuccess'))
      batchGroupDialogVisible.value = false
      selectedRows.value = []
      await loadMatnrList()
    } catch (error) {
      ElMessage.error(error?.message || t('crud.messages.submitFailed'))
    }
  }
  async function handleBindLocSubmit(formData) {
    const ids = ensureSelectedRows()
    if (!ids.length) {
      bindLocDialogVisible.value = false
      return
    }
    try {
      await fetchBindLocAreaMatRelaByMatnr({
        ...formData,
        matnrId: ids
      })
      ElMessage.success(t('crud.messages.updateSuccess'))
      bindLocDialogVisible.value = false
      selectedRows.value = []
      await loadMatnrList()
    } catch (error) {
      ElMessage.error(error?.message || t('crud.messages.submitFailed'))
    }
  }
  const buildPreviewMeta = (rows) => {
    const now = new Date()
    return {
      reportDate: now.toLocaleDateString('zh-CN'),
      printedAt: now.toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length,
      reportStyle: { ...WH_MAT_REPORT_STYLE }
    }
  }
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetMatnrMany(payload.ids)).records
    }
    return tableData.value
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'matnr.xlsx',
    requestExport: (payload) =>
      fetchExportMatnrReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildWhMatPrintRows(records, t),
    buildPreviewMeta
  })
  const resolvedPreviewMeta = computed(() =>
    buildWhMatReportMeta({
      previewMeta: previewMeta.value,
      count: previewRows.value.length,
      orientation: previewMeta.value?.reportStyle?.orientation || WH_MAT_REPORT_STYLE.orientation
    })
  )
  async function downloadFile(response, fallbackName) {
    if (!response?.ok) {
      throw new Error(
        t('crud.messages.exportFailedWithStatus', { status: response?.status || '-' })
      )
    }
    const blob = await response.blob()
    const downloadUrl = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = downloadUrl
    link.download = fallbackName
    document.body.appendChild(link)
    link.click()
    link.remove()
    window.URL.revokeObjectURL(downloadUrl)
  }
  function handleShowDialog(type) {
    showDialog(type)
  }
  function handleSearch(params) {
    searchForm.value = {
      ...searchForm.value,
      ...params
      ...params,
      orderBy: searchForm.value?.orderBy || 'create_time desc'
    }
    pagination.current = 1
    if (searchForm.value.groupId) {
      selectedGroupId.value = null
    }
    loadMatnrList()
  }
  async function handleReset() {
    searchForm.value = createWhMatSearchState()
    enabledFields.value.forEach((field) => {
      searchForm.value[getWhMatDynamicFieldKey(field.fields)] = ''
    })
    pagination.current = 1
    selectedGroupId.value = null
    groupSearch.value = ''
    await Promise.all([loadGroupTree(), loadMatnrList()])
  }
  function handleRefresh() {
    loadMatnrList()
  }
  function handleSizeChange(size) {
@@ -318,6 +1135,7 @@
  function handleGroupNodeClick(data) {
    selectedGroupId.value = data?.id ?? null
    delete searchForm.value.groupId
    pagination.current = 1
    loadMatnrList()
  }
@@ -335,46 +1153,154 @@
    await Promise.all([loadGroupTree(), loadMatnrList()])
  }
  async function handleImportFileChange(uploadFile) {
    if (!uploadFile?.raw) {
      return
    }
    importing.value = true
    try {
      await fetchImportMatnr(uploadFile.raw)
      ElMessage.success(t('pages.basicInfo.whMat.messages.importSuccess'))
      await loadMatnrList()
    } catch (error) {
      ElMessage.error(error?.message || t('pages.basicInfo.whMat.messages.importFailed'))
    } finally {
      importing.value = false
    }
  }
  async function handleDownloadTemplate() {
    templateDownloading.value = true
    try {
      const response = await fetchDownloadMatnrTemplate(
        {},
        {
          headers: {
            Authorization: userStore.accessToken || ''
          }
        }
      )
      await downloadFile(response, 'matnr-template.xlsx')
      ElMessage.success(t('pages.basicInfo.whMat.messages.templateDownloadSuccess'))
    } catch (error) {
      ElMessage.error(error?.message || t('pages.basicInfo.whMat.messages.templateDownloadFailed'))
    } finally {
      templateDownloading.value = false
    }
  }
  onMounted(async () => {
    await Promise.all([loadGroupTree(), loadMatnrList()])
    await Promise.allSettled([
      loadEnabledFieldDefinitions(),
      loadGroupTree(),
      loadSerialRuleOptions()
    ])
    await loadMatnrList()
  })
</script>
<style scoped>
  .wh-mat-page-root {
    height: 100%;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }
  .wh-mat-page {
    display: flex;
    flex-direction: row;
    align-items: flex-start;
    align-items: stretch;
    gap: 16px;
    flex: 1 1 auto;
    min-height: 0;
  }
  .wh-mat-page__sidebar {
    width: 320px;
    flex: 0 0 320px;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }
  .wh-mat-page__sidebar-card {
    position: sticky;
    top: 16px;
    height: 100%;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  .wh-mat-page__sidebar-card :deep(.el-card__body) {
    height: 100%;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  .wh-mat-page__tree-scroll {
    height: calc(100vh - 320px);
    min-height: 420px;
    flex: 1 1 auto;
    min-height: 0;
  }
  .wh-mat-page__content {
    height: 100%;
    min-width: 0;
    min-height: 0;
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  .wh-mat-page__content > * + * {
    margin-top: 16px;
  }
  .wh-mat-page__content > :deep(.art-search-bar) {
    flex: 0 0 auto;
  }
  .wh-mat-page__content > :deep(.art-table-card) {
    flex: 1 1 auto;
    min-height: 0;
    overflow: hidden;
  }
  .wh-mat-page__content > :deep(.art-table-card .el-card__body) {
    display: flex;
    min-height: 0;
    flex-direction: column;
  }
  .wh-mat-page__content > :deep(.art-table-card #art-table-header) {
    flex: 0 0 auto;
  }
  .wh-mat-page__content > :deep(.art-table-card .art-table) {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  .wh-mat-page__content > :deep(.art-table-card .art-table .el-table) {
    flex: 1 1 auto;
    min-height: 0;
    height: auto;
  }
  .wh-mat-page__content > :deep(.art-table-card .art-table .pagination) {
    flex: 0 0 auto;
  }
  @media (max-width: 1024px) {
    .wh-mat-page {
      flex-direction: column;
      flex: none;
    }
    .wh-mat-page__sidebar {
@@ -383,7 +1309,7 @@
    }
    .wh-mat-page__sidebar-card {
      position: static;
      height: auto;
    }
    .wh-mat-page__tree-scroll {