zhou zhou
2 天以前 a50cc7a916a14628ae16f3c5c5578cc433e23a3d
feat: add warehouse areas page
7个文件已添加
2个文件已修改
1386 ■■■■■ 已修改文件
rsf-design/src/api/warehouse-areas.js 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/adapters/backendMenuAdapter.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/routes/staticRoutes.js 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/warehouse-areas/index.vue 303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/warehouse-areas/modules/warehouse-areas-detail-drawer.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/warehouse-areas/modules/warehouse-areas-dialog.vue 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/warehouse-areas/warehouseAreasPage.helpers.js 230 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/warehouse-areas/warehouseAreasTable.columns.js 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/tests/basic-info-warehouse-areas-page-contract.test.mjs 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/warehouse-areas.js
New file
@@ -0,0 +1,184 @@
import request from '@/utils/http'
function normalizeText(value) {
  return typeof value === 'string' ? value.trim() : value
}
function normalizeIds(ids) {
  if (Array.isArray(ids)) {
    return ids
      .map((id) => String(id).trim())
      .filter(Boolean)
      .join(',')
  }
  if (ids === null || ids === undefined) {
    return ''
  }
  return String(ids).trim()
}
function filterParams(params = {}, ignoredKeys = []) {
  return Object.fromEntries(
    Object.entries(params)
      .filter(([key, value]) => {
        if (ignoredKeys.includes(key)) return false
        if (value === undefined || value === null) return false
        if (typeof value === 'string' && value.trim() === '') return false
        return true
      })
      .map(([key, value]) => [key, normalizeText(value)])
  )
}
export function buildWarehouseAreasPageParams(params = {}) {
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    ...filterParams(params, ['current', 'pageSize', 'size'])
  }
}
export function buildWarehouseAreasSavePayload(formData = {}) {
  return {
    ...(formData.id !== undefined && formData.id !== null && formData.id !== ''
      ? { id: Number(formData.id) }
      : {}),
    ...(formData.warehouseId !== undefined && formData.warehouseId !== null && formData.warehouseId !== ''
      ? { warehouseId: Number(formData.warehouseId) }
      : {}),
    code: normalizeText(formData.code) || '',
    name: normalizeText(formData.name) || '',
    type: normalizeText(formData.type) || '',
    ...(formData.shipperId !== undefined && formData.shipperId !== null && formData.shipperId !== ''
      ? { shipperId: Number(formData.shipperId) }
      : {}),
    ...(formData.supplierId !== undefined && formData.supplierId !== null && formData.supplierId !== ''
      ? { supplierId: Number(formData.supplierId) }
      : {}),
    ...(formData.flagMinus !== undefined && formData.flagMinus !== null && formData.flagMinus !== ''
      ? { flagMinus: Number(formData.flagMinus) }
      : {}),
    ...(formData.flagLabelMange !== undefined && formData.flagLabelMange !== null && formData.flagLabelMange !== ''
      ? { flagLabelMange: Number(formData.flagLabelMange) }
      : {}),
    ...(formData.flagMix !== undefined && formData.flagMix !== null && formData.flagMix !== ''
      ? { flagMix: Number(formData.flagMix) }
      : {}),
    status:
      formData.status !== undefined && formData.status !== null && formData.status !== ''
        ? Number(formData.status)
        : 1,
    ...(formData.sort !== undefined && formData.sort !== null && formData.sort !== ''
      ? { sort: Number(formData.sort) }
      : {}),
    memo: normalizeText(formData.memo) || ''
  }
}
export function buildWarehouseAreasSearchParams(params = {}) {
  const searchParams = {
    condition: normalizeText(params.condition),
    warehouseId:
      params.warehouseId !== undefined && params.warehouseId !== null && params.warehouseId !== ''
        ? Number(params.warehouseId)
        : void 0,
    code: normalizeText(params.code),
    name: normalizeText(params.name),
    type: normalizeText(params.type),
    shipperId:
      params.shipperId !== undefined && params.shipperId !== null && params.shipperId !== ''
        ? Number(params.shipperId)
        : void 0,
    supplierId:
      params.supplierId !== undefined && params.supplierId !== null && params.supplierId !== ''
        ? Number(params.supplierId)
        : void 0,
    status:
      params.status !== undefined && params.status !== null && params.status !== ''
        ? Number(params.status)
        : void 0,
    memo: normalizeText(params.memo)
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
  )
}
export function fetchWarehouseAreasPage(params = {}) {
  return request.post({
    url: '/warehouseAreas/page',
    params: buildWarehouseAreasPageParams(params)
  })
}
export function fetchWarehouseAreasList() {
  return request.post({
    url: '/warehouseAreas/list',
    data: {}
  })
}
export function fetchWarehouseAreasDetail(id) {
  return request.get({
    url: `/warehouseAreas/${id}`
  })
}
export function fetchWarehouseAreasMany(ids) {
  return request.post({
    url: `/warehouseAreas/many/${normalizeIds(ids)}`
  })
}
export function fetchSaveWarehouseAreas(params = {}) {
  return request.post({
    url: '/warehouseAreas/save',
    params: buildWarehouseAreasSavePayload(params)
  })
}
export function fetchUpdateWarehouseAreas(params = {}) {
  return request.post({
    url: '/warehouseAreas/update',
    params: buildWarehouseAreasSavePayload(params)
  })
}
export function fetchDeleteWarehouseAreas(ids) {
  return request.post({
    url: `/warehouseAreas/remove/${normalizeIds(ids)}`
  })
}
export function fetchWarehouseAreasQuery(condition = '') {
  return request.post({
    url: '/warehouseAreas/query',
    params: { condition: normalizeText(condition) }
  })
}
export function fetchWarehouseList() {
  return request.post({
    url: '/warehouse/list',
    data: {}
  })
}
export function fetchCompanysList() {
  return request.post({
    url: '/companys/list',
    data: {}
  })
}
export async function fetchExportWarehouseAreasReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/warehouseAreas/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
rsf-design/src/router/adapters/backendMenuAdapter.js
@@ -19,6 +19,10 @@
  fieldsItem: '/system/fields-item',
  whMat: '/basic-info/wh-mat',
  matnr: '/basic-info/wh-mat',
  matnrGroup: '/basic-info/matnr-group',
  basContainer: '/basic-info/bas-container',
  warehouse: '/basic-info/warehouse',
  warehouseAreas: '/basic-info/warehouse-areas',
  warehouseStock: '/stock/warehouse-stock',
  warehouseAreasItem: '/stock/warehouse-areas-item',
  qlyInspect: '/manager/qly-inspect',
rsf-design/src/router/routes/staticRoutes.js
@@ -40,6 +40,46 @@
          icon: 'ri:bill-line',
          keepAlive: false
        }
      },
      {
        path: 'matnr-group',
        name: 'MatnrGroup',
        component: () => import('@views/basic-info/matnr-group/index.vue'),
        meta: {
          title: 'menu.matnrGroup',
          icon: 'ri:git-branch-line',
          keepAlive: false
        }
      },
      {
        path: 'bas-container',
        name: 'BasContainer',
        component: () => import('@views/basic-info/bas-container/index.vue'),
        meta: {
          title: 'menu.basContainer',
          icon: 'ri:archive-line',
          keepAlive: false
        }
      },
      {
        path: 'warehouse',
        name: 'Warehouse',
        component: () => import('@views/basic-info/warehouse/index.vue'),
        meta: {
          title: 'menu.warehouse',
          icon: 'ri:store-2-line',
          keepAlive: false
        }
      },
      {
        path: 'warehouse-areas',
        name: 'WarehouseAreas',
        component: () => import('@views/basic-info/warehouse-areas/index.vue'),
        meta: {
          title: 'menu.warehouseAreas',
          icon: 'ri:layout-grid-line',
          keepAlive: false
        }
      }
    ]
  },
rsf-design/src/views/basic-info/warehouse-areas/index.vue
New file
@@ -0,0 +1,303 @@
<template>
  <div class="warehouse-areas-page art-full-height">
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
      :showExpand="true"
      @search="handleSearch"
      @reset="handleReset"
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ElSpace wrap>
            <ElButton v-auth="'add'" @click="showDialog('add')" v-ripple>新增库区</ElButton>
            <ElButton
              v-auth="'delete'"
              type="danger"
              :disabled="selectedRows.length === 0"
              @click="handleBatchDelete"
              v-ripple
            >
              批量删除
            </ElButton>
          </ElSpace>
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
        :data="data"
        :columns="columns"
        :pagination="pagination"
        @selection-change="handleSelectionChange"
        @pagination:size-change="handleSizeChange"
        @pagination:current-change="handleCurrentChange"
      />
      <WarehouseAreasDialog
        v-model:visible="dialogVisible"
        :dialog-type="dialogType"
        :warehouse-areas-data="currentWarehouseAreasData"
        :warehouse-options="warehouseOptions"
        :shipper-options="shipperOptions"
        :supplier-options="supplierOptions"
        :type-options="typeOptions"
        @submit="handleDialogSubmit"
      />
      <WarehouseAreasDetailDrawer
        v-model:visible="detailDrawerVisible"
        :loading="detailLoading"
        :detail="detailData"
      />
    </ElCard>
  </div>
</template>
<script setup>
  import { computed, onMounted, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { useAuth } from '@/hooks/core/useAuth'
  import { useTable } from '@/hooks/core/useTable'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { useCrudPage } from '@/views/system/common/useCrudPage'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchDictDataPage } from '@/api/system-manage'
  import {
    fetchCompanysList,
    fetchDeleteWarehouseAreas,
    fetchWarehouseAreasDetail,
    fetchWarehouseAreasPage,
    fetchSaveWarehouseAreas,
    fetchUpdateWarehouseAreas,
    fetchWarehouseList
  } from '@/api/warehouse-areas'
  import WarehouseAreasDialog from './modules/warehouse-areas-dialog.vue'
  import WarehouseAreasDetailDrawer from './modules/warehouse-areas-detail-drawer.vue'
  import { createWarehouseAreasTableColumns } from './warehouseAreasTable.columns'
  import {
    buildWarehouseAreasDialogModel,
    buildWarehouseAreasPageQueryParams,
    buildWarehouseAreasSavePayload,
    buildWarehouseAreasSearchParams,
    createWarehouseAreasSearchState,
    getWarehouseAreasPaginationKey,
    getWarehouseAreasStatusOptions,
    normalizeWarehouseAreasDetailRecord,
    normalizeWarehouseAreasListRow
  } from './warehouseAreasPage.helpers'
  defineOptions({ name: 'WarehouseAreas' })
  const { hasAuth } = useAuth()
  const searchForm = ref(createWarehouseAreasSearchState())
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const detailData = ref({})
  const warehouseOptions = ref([])
  const shipperOptions = ref([])
  const supplierOptions = ref([])
  const typeOptions = ref([])
  let handleDeleteAction = null
  const searchItems = computed(() => [
    {
      label: '关键字',
      key: 'condition',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入库区名称/编码/备注'
      }
    },
    {
      label: '仓库',
      key: 'warehouseId',
      type: 'select',
      props: {
        clearable: true,
        filterable: true,
        options: warehouseOptions.value
      }
    },
    {
      label: '库区编码',
      key: 'code',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入库区编码'
      }
    },
    {
      label: '库区名称',
      key: 'name',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入库区名称'
      }
    },
    {
      label: '业务类型',
      key: 'type',
      type: 'select',
      props: {
        clearable: true,
        filterable: true,
        options: typeOptions.value
      }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        clearable: true,
        options: getWarehouseAreasStatusOptions()
      }
    }
  ])
  async function openDetail(row) {
    detailDrawerVisible.value = true
    detailLoading.value = true
    try {
      const detail = await guardRequestWithMessage(fetchWarehouseAreasDetail(row.id), {}, {
        timeoutMessage: '库区详情加载超时,已停止等待'
      })
      detailData.value = normalizeWarehouseAreasDetailRecord(detail)
    } catch (error) {
      detailDrawerVisible.value = false
      detailData.value = {}
      ElMessage.error(error?.message || '获取库区详情失败')
    } finally {
      detailLoading.value = false
    }
  }
  async function openEditDialog(row) {
    try {
      const detail = await guardRequestWithMessage(fetchWarehouseAreasDetail(row.id), {}, {
        timeoutMessage: '库区详情加载超时,已停止等待'
      })
      showDialog('edit', detail)
    } catch (error) {
      ElMessage.error(error?.message || '获取库区详情失败')
    }
  }
  const { columns, columnChecks, data, loading, pagination, getData, replaceSearchParams, resetSearchParams, handleSizeChange, handleCurrentChange, refreshData, refreshCreate, refreshUpdate, refreshRemove } =
    useTable({
      core: {
        apiFn: fetchWarehouseAreasPage,
        apiParams: buildWarehouseAreasPageQueryParams(searchForm.value),
        paginationKey: getWarehouseAreasPaginationKey(),
        columnsFactory: () =>
          createWarehouseAreasTableColumns({
            handleView: openDetail,
            handleEdit: hasAuth('update') ? openEditDialog : null,
            handleDelete: hasAuth('delete') ? (row) => handleDeleteAction?.(row) : null,
            canEdit: hasAuth('update'),
            canDelete: hasAuth('delete')
          })
      },
      transform: {
        dataTransformer: (records) => {
          if (!Array.isArray(records)) {
            return []
          }
          return records.map((item) => normalizeWarehouseAreasListRow(item))
        }
      }
    })
  const {
    dialogVisible,
    dialogType,
    currentRecord: currentWarehouseAreasData,
    selectedRows,
    handleSelectionChange,
    showDialog,
    handleDialogSubmit,
    handleDelete,
    handleBatchDelete
  } = useCrudPage({
    createEmptyModel: () => buildWarehouseAreasDialogModel(),
    buildEditModel: (record) => buildWarehouseAreasDialogModel(record),
    buildSavePayload: (formData) => buildWarehouseAreasSavePayload(formData),
    saveRequest: fetchSaveWarehouseAreas,
    updateRequest: fetchUpdateWarehouseAreas,
    deleteRequest: fetchDeleteWarehouseAreas,
    entityName: '库区',
    resolveRecordLabel: (record) => record?.name || record?.code || record?.id,
    refreshCreate,
    refreshUpdate,
    refreshRemove
  })
  handleDeleteAction = handleDelete
  function handleSearch(params) {
    replaceSearchParams(buildWarehouseAreasSearchParams(params))
    getData()
  }
  function handleReset() {
    Object.assign(searchForm.value, createWarehouseAreasSearchState())
    resetSearchParams()
  }
  async function loadWarehouseOptions() {
    const records = await guardRequestWithMessage(fetchWarehouseList(), [], {
      timeoutMessage: '仓库选项加载超时,已停止等待'
    })
    warehouseOptions.value = defaultResponseAdapter(records).records.map((item) => ({
      label: item.name || item.code || `仓库 ${item.id}`,
      value: item.id
    }))
  }
  async function loadCompanyOptions() {
    const records = await guardRequestWithMessage(fetchCompanysList(), [], {
      timeoutMessage: '往来企业选项加载超时,已停止等待'
    })
    const normalizedRecords = defaultResponseAdapter(records).records
    shipperOptions.value = normalizedRecords
      .filter((item) => item.type === 'shipper')
      .map((item) => ({
        label: item.name || item.code || `货主 ${item.id}`,
        value: item.id
      }))
    supplierOptions.value = normalizedRecords
      .filter((item) => item.type === 'supplier')
      .map((item) => ({
        label: item.name || item.code || `供应商 ${item.id}`,
        value: item.id
      }))
  }
  async function loadTypeOptions() {
    const response = await guardRequestWithMessage(
      fetchDictDataPage({
        current: 1,
        pageSize: 200,
        dictTypeCode: 'sys_ware_areas_type',
        status: 1
      }),
      { records: [] },
      { timeoutMessage: '业务类型加载超时,已停止等待' }
    )
    typeOptions.value = defaultResponseAdapter(response).records.map((item) => ({
      label: item.label || item.name || item.value,
      value: item.value
    }))
  }
  onMounted(async () => {
    await Promise.all([loadWarehouseOptions(), loadCompanyOptions(), loadTypeOptions(), getData()])
  })
</script>
rsf-design/src/views/basic-info/warehouse-areas/modules/warehouse-areas-detail-drawer.vue
New file
@@ -0,0 +1,63 @@
<template>
  <ElDrawer
    :model-value="visible"
    title="库区详情"
    size="960px"
    destroy-on-close
    @update:model-value="handleVisibleChange"
  >
    <ElScrollbar class="h-[calc(100vh-180px)] pr-1">
      <div v-if="loading" class="py-6">
        <ElSkeleton :rows="12" animated />
      </div>
      <div v-else class="space-y-4">
        <ElDescriptions title="基础信息" :column="2" border>
          <ElDescriptionsItem label="仓库">{{ detail.warehouseName || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="库区编码">{{ detail.code || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="库区名称">{{ detail.name || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="业务类型">{{ detail.typeText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="货主">{{ detail.shipperName || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="供应商">{{ detail.supplierName || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="支持混放">{{ detail.flagMixText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="允许负库存">{{ detail.flagMinusText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="标签管理">{{ detail.flagLabelMangeText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="状态">
            <ElTag :type="detail.statusType || 'info'" effect="light">
              {{ detail.statusText || '--' }}
            </ElTag>
          </ElDescriptionsItem>
          <ElDescriptionsItem label="排序">{{ detail.sort ?? '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="备注" :span="2">{{ detail.memo || '--' }}</ElDescriptionsItem>
        </ElDescriptions>
        <ElDescriptions title="审计信息" :column="2" border>
          <ElDescriptionsItem label="创建人">{{ detail.createByText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="创建时间">{{ detail.createTimeText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="更新人">{{ detail.updateByText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="更新时间">{{ detail.updateTimeText || '--' }}</ElDescriptionsItem>
        </ElDescriptions>
      </div>
    </ElScrollbar>
  </ElDrawer>
</template>
<script setup>
  import { computed } from 'vue'
  const props = defineProps({
    visible: { type: Boolean, default: false },
    loading: { type: Boolean, default: false },
    detail: { type: Object, default: () => ({}) }
  })
  const emit = defineEmits(['update:visible'])
  const visible = computed({
    get: () => props.visible,
    set: (value) => emit('update:visible', value)
  })
  function handleVisibleChange(value) {
    visible.value = value
  }
</script>
rsf-design/src/views/basic-info/warehouse-areas/modules/warehouse-areas-dialog.vue
New file
@@ -0,0 +1,240 @@
<template>
  <ElDialog
    :title="dialogTitle"
    :model-value="visible"
    width="920px"
    align-center
    destroy-on-close
    @update:model-value="handleCancel"
    @closed="handleClosed"
  >
    <ArtForm
      ref="formRef"
      v-model="form"
      :items="formItems"
      :rules="rules"
      :span="12"
      :gutter="20"
      label-width="110px"
      :show-reset="false"
      :show-submit="false"
    />
    <template #footer>
      <span class="dialog-footer">
        <ElButton @click="handleCancel">取消</ElButton>
        <ElButton type="primary" @click="handleSubmit">确定</ElButton>
      </span>
    </template>
  </ElDialog>
</template>
<script setup>
  import { computed, nextTick, reactive, ref, watch } from 'vue'
  import ArtForm from '@/components/core/forms/art-form/index.vue'
  import {
    buildWarehouseAreasDialogModel,
    createWarehouseAreasFormState,
    getWarehouseAreasFlagOptions,
    getWarehouseAreasStatusOptions
  } from '../warehouseAreasPage.helpers'
  const props = defineProps({
    visible: { type: Boolean, default: false },
    dialogType: { type: String, default: 'add' },
    warehouseAreasData: { type: Object, default: () => ({}) },
    warehouseOptions: { type: Array, default: () => [] },
    shipperOptions: { type: Array, default: () => [] },
    supplierOptions: { type: Array, default: () => [] },
    typeOptions: { type: Array, default: () => [] }
  })
  const emit = defineEmits(['update:visible', 'submit'])
  const formRef = ref()
  const form = reactive(createWarehouseAreasFormState())
  const isEdit = computed(() => props.dialogType === 'edit')
  const dialogTitle = computed(() => (isEdit.value ? '编辑库区' : '新增库区'))
  const rules = computed(() => ({
    warehouseId: [{ required: true, message: '请选择仓库', trigger: 'change' }],
    code: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
    name: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
    type: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
    flagMinus: [{ required: true, message: '请选择允许负库存', trigger: 'change' }],
    flagMix: [{ required: true, message: '请选择支持混放', trigger: 'change' }]
  }))
  const formItems = computed(() => [
    {
      label: '仓库',
      key: 'warehouseId',
      type: 'select',
      props: {
        placeholder: '请选择仓库',
        clearable: true,
        filterable: true,
        options: props.warehouseOptions
      }
    },
    {
      label: '库区编码',
      key: 'code',
      type: 'input',
      props: {
        placeholder: '请输入库区编码',
        clearable: true
      }
    },
    {
      label: '库区名称',
      key: 'name',
      type: 'input',
      props: {
        placeholder: '请输入库区名称',
        clearable: true
      }
    },
    {
      label: '业务类型',
      key: 'type',
      type: 'select',
      props: {
        placeholder: '请选择业务类型',
        clearable: true,
        filterable: true,
        options: props.typeOptions
      }
    },
    {
      label: '货主',
      key: 'shipperId',
      type: 'select',
      props: {
        placeholder: '请选择货主',
        clearable: true,
        filterable: true,
        options: props.shipperOptions
      }
    },
    {
      label: '供应商',
      key: 'supplierId',
      type: 'select',
      props: {
        placeholder: '请选择供应商',
        clearable: true,
        filterable: true,
        options: props.supplierOptions
      }
    },
    {
      label: '允许负库存',
      key: 'flagMinus',
      type: 'select',
      props: {
        placeholder: '请选择',
        options: getWarehouseAreasFlagOptions()
      }
    },
    {
      label: '标签管理',
      key: 'flagLabelMange',
      type: 'select',
      props: {
        placeholder: '请选择',
        options: getWarehouseAreasFlagOptions()
      }
    },
    {
      label: '支持混放',
      key: 'flagMix',
      type: 'select',
      props: {
        placeholder: '请选择',
        options: getWarehouseAreasFlagOptions()
      }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        placeholder: '请选择状态',
        options: getWarehouseAreasStatusOptions()
      }
    },
    {
      label: '排序',
      key: 'sort',
      type: 'number',
      props: {
        min: 0,
        controlsPosition: 'right',
        style: { width: '100%' }
      }
    },
    {
      label: '备注',
      key: 'memo',
      type: 'input',
      span: 24,
      props: {
        type: 'textarea',
        rows: 3,
        placeholder: '请输入备注',
        clearable: true
      }
    }
  ])
  const loadFormData = () => {
    Object.assign(form, buildWarehouseAreasDialogModel(props.warehouseAreasData))
  }
  const resetForm = () => {
    Object.assign(form, createWarehouseAreasFormState())
    formRef.value?.clearValidate?.()
  }
  const handleSubmit = async () => {
    if (!formRef.value) return
    try {
      await formRef.value.validate()
      emit('submit', { ...form })
    } catch {
      return
    }
  }
  const handleCancel = () => {
    emit('update:visible', false)
  }
  const handleClosed = () => {
    resetForm()
  }
  watch(
    () => props.visible,
    (visible) => {
      if (visible) {
        loadFormData()
        nextTick(() => {
          formRef.value?.clearValidate?.()
        })
      }
    },
    { immediate: true }
  )
  watch(
    () => props.warehouseAreasData,
    () => {
      if (props.visible) {
        loadFormData()
      }
    },
    { deep: true }
  )
</script>
rsf-design/src/views/basic-info/warehouse-areas/warehouseAreasPage.helpers.js
New file
@@ -0,0 +1,230 @@
const STATUS_META = {
  1: { text: '正常', type: 'success', bool: true },
  0: { text: '冻结', type: 'danger', bool: false }
}
export const WAREHOUSE_AREAS_REPORT_TITLE = '库区报表'
export const WAREHOUSE_AREAS_REPORT_STYLE = {
  titleAlign: 'center',
  titleLevel: 'strong',
  orientation: 'portrait',
  density: 'compact',
  showSequence: true
}
function normalizeText(value) {
  return String(value ?? '').trim()
}
function normalizeFlagText(value) {
  if (value === 1 || value === '1' || value === true || value === '是') {
    return '是'
  }
  if (value === 0 || value === '0' || value === false || value === '否') {
    return '否'
  }
  return normalizeText(value) || '-'
}
export function createWarehouseAreasSearchState() {
  return {
    condition: '',
    warehouseId: '',
    code: '',
    name: '',
    type: '',
    shipperId: '',
    supplierId: '',
    status: '',
    memo: ''
  }
}
export function createWarehouseAreasFormState() {
  return {
    id: void 0,
    warehouseId: void 0,
    code: '',
    name: '',
    type: '',
    shipperId: void 0,
    supplierId: void 0,
    flagMinus: 0,
    flagLabelMange: 0,
    flagMix: 0,
    status: 1,
    sort: void 0,
    memo: ''
  }
}
export function getWarehouseAreasPaginationKey() {
  return {
    current: 'current',
    size: 'pageSize'
  }
}
export function getWarehouseAreasStatusOptions() {
  return [
    { label: '正常', value: 1 },
    { label: '冻结', value: 0 }
  ]
}
export function getWarehouseAreasFlagOptions() {
  return [
    { label: '否', value: 0 },
    { label: '是', value: 1 }
  ]
}
export function buildWarehouseAreasSearchParams(params = {}) {
  const searchParams = {
    condition: normalizeText(params.condition),
    warehouseId:
      params.warehouseId !== undefined && params.warehouseId !== null && params.warehouseId !== ''
        ? Number(params.warehouseId)
        : void 0,
    code: normalizeText(params.code),
    name: normalizeText(params.name),
    type: normalizeText(params.type),
    shipperId:
      params.shipperId !== undefined && params.shipperId !== null && params.shipperId !== ''
        ? Number(params.shipperId)
        : void 0,
    supplierId:
      params.supplierId !== undefined && params.supplierId !== null && params.supplierId !== ''
        ? Number(params.supplierId)
        : void 0,
    status:
      params.status !== undefined && params.status !== null && params.status !== ''
        ? Number(params.status)
        : void 0,
    memo: normalizeText(params.memo)
  }
  return Object.fromEntries(
    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
  )
}
export function buildWarehouseAreasPageQueryParams(params = {}) {
  return {
    current: params.current || 1,
    pageSize: params.pageSize || params.size || 20,
    ...buildWarehouseAreasSearchParams(params)
  }
}
export function buildWarehouseAreasSavePayload(formData = {}) {
  return {
    ...(formData.id !== void 0 && formData.id !== null && formData.id !== ''
      ? { id: Number(formData.id) }
      : {}),
    ...(formData.warehouseId !== void 0 && formData.warehouseId !== null && formData.warehouseId !== ''
      ? { warehouseId: Number(formData.warehouseId) }
      : {}),
    code: normalizeText(formData.code) || '',
    name: normalizeText(formData.name) || '',
    type: normalizeText(formData.type) || '',
    ...(formData.shipperId !== void 0 && formData.shipperId !== null && formData.shipperId !== ''
      ? { shipperId: Number(formData.shipperId) }
      : {}),
    ...(formData.supplierId !== void 0 && formData.supplierId !== null && formData.supplierId !== ''
      ? { supplierId: Number(formData.supplierId) }
      : {}),
    ...(formData.flagMinus !== void 0 && formData.flagMinus !== null && formData.flagMinus !== ''
      ? { flagMinus: Number(formData.flagMinus) }
      : {}),
    ...(formData.flagLabelMange !== void 0 && formData.flagLabelMange !== null && formData.flagLabelMange !== ''
      ? { flagLabelMange: Number(formData.flagLabelMange) }
      : {}),
    ...(formData.flagMix !== void 0 && formData.flagMix !== null && formData.flagMix !== ''
      ? { flagMix: Number(formData.flagMix) }
      : {}),
    status:
      formData.status !== void 0 && formData.status !== null && formData.status !== ''
        ? Number(formData.status)
        : 1,
    ...(formData.sort !== void 0 && formData.sort !== null && formData.sort !== ''
      ? { sort: Number(formData.sort) }
      : {}),
    memo: normalizeText(formData.memo) || ''
  }
}
export function buildWarehouseAreasDialogModel(record = {}) {
  return {
    ...createWarehouseAreasFormState(),
    ...(record.id !== void 0 && record.id !== null && record.id !== '' ? { id: Number(record.id) } : {}),
    warehouseId:
      record.warehouseId !== void 0 && record.warehouseId !== null && record.warehouseId !== ''
        ? Number(record.warehouseId)
        : void 0,
    code: normalizeText(record.code || ''),
    name: normalizeText(record.name || ''),
    type: normalizeText(record.type || ''),
    shipperId:
      record.shipperId !== void 0 && record.shipperId !== null && record.shipperId !== ''
        ? Number(record.shipperId)
        : void 0,
    supplierId:
      record.supplierId !== void 0 && record.supplierId !== null && record.supplierId !== ''
        ? Number(record.supplierId)
        : void 0,
    flagMinus: record.flagMinus !== void 0 && record.flagMinus !== null ? Number(record.flagMinus) : 0,
    flagLabelMange:
      record.flagLabelMange !== void 0 && record.flagLabelMange !== null ? Number(record.flagLabelMange) : 0,
    flagMix: record.flagMix !== void 0 && record.flagMix !== null ? Number(record.flagMix) : 0,
    status: record.status !== void 0 && record.status !== null ? Number(record.status) : 1,
    sort: record.sort !== void 0 && record.sort !== null && record.sort !== '' ? Number(record.sort) : void 0,
    memo: normalizeText(record.memo || '')
  }
}
export function getWarehouseAreasStatusMeta(status) {
  if (status === true || Number(status) === 1) {
    return STATUS_META[1]
  }
  if (status === false || Number(status) === 0) {
    return STATUS_META[0]
  }
  return { text: '未知', type: 'info', bool: false }
}
export function normalizeWarehouseAreasDetailRecord(record = {}) {
  const statusMeta = getWarehouseAreasStatusMeta(record.statusBool ?? record.status)
  return {
    ...record,
    warehouseName: normalizeText(record.warehouseId$ || record.warehouseName || ''),
    typeText: normalizeText(record.type$ || record.type || ''),
    shipperName: normalizeText(record.shipperId$ || record.shipperName || ''),
    supplierName: normalizeText(record.supplierId$ || record.supplierName || ''),
    code: normalizeText(record.code || ''),
    name: normalizeText(record.name || ''),
    memo: normalizeText(record.memo || ''),
    flagMinusText: normalizeFlagText(record.flagMinus$ ?? record.flagMinus),
    flagLabelMangeText: normalizeFlagText(record.flagLabelMange$ ?? record.flagLabelMange),
    flagMixText: normalizeFlagText(record.flagMix$ ?? record.flagMix),
    statusText: statusMeta.text,
    statusType: statusMeta.type,
    statusBool: record.statusBool !== void 0 ? Boolean(record.statusBool) : statusMeta.bool,
    createByText: normalizeText(record.createBy$ || record.createByText || ''),
    createTimeText: normalizeText(record.createTime$ || record.createTime || ''),
    updateByText: normalizeText(record.updateBy$ || record.updateByText || ''),
    updateTimeText: normalizeText(record.updateTime$ || record.updateTime || '')
  }
}
export function normalizeWarehouseAreasListRow(record = {}) {
  return normalizeWarehouseAreasDetailRecord(record)
}
export function buildWarehouseAreasPrintRows(records = []) {
  if (!Array.isArray(records)) {
    return []
  }
  return records.map((record) => normalizeWarehouseAreasListRow(record))
}
rsf-design/src/views/basic-info/warehouse-areas/warehouseAreasTable.columns.js
New file
@@ -0,0 +1,145 @@
import { h } from 'vue'
import { ElTag } from 'element-plus'
import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue'
import { getWarehouseAreasStatusMeta } from './warehouseAreasPage.helpers'
export function createWarehouseAreasTableColumns({
  handleView,
  handleEdit,
  handleDelete,
  canEdit = true,
  canDelete = true
} = {}) {
  const operations = [{ key: 'view', label: '详情', icon: 'ri:eye-line' }]
  if (canEdit && handleEdit) {
    operations.push({ key: 'edit', label: '编辑', icon: 'ri:pencil-line' })
  }
  if (canDelete && handleDelete) {
    operations.push({ key: 'delete', label: '删除', icon: 'ri:delete-bin-5-line', color: 'var(--art-error)' })
  }
  return [
    {
      type: 'selection',
      width: 48,
      align: 'center'
    },
    {
      type: 'globalIndex',
      label: '序号',
      width: 72,
      align: 'center'
    },
    {
      prop: 'warehouseName',
      label: '仓库',
      minWidth: 160,
      showOverflowTooltip: true,
      formatter: (row) => row.warehouseName || '--'
    },
    {
      prop: 'code',
      label: '库区编码',
      minWidth: 150,
      showOverflowTooltip: true,
      formatter: (row) => row.code || '--'
    },
    {
      prop: 'name',
      label: '库区名称',
      minWidth: 180,
      showOverflowTooltip: true,
      formatter: (row) => row.name || '--'
    },
    {
      prop: 'typeText',
      label: '业务类型',
      minWidth: 140,
      showOverflowTooltip: true,
      formatter: (row) => row.typeText || '--'
    },
    {
      prop: 'shipperName',
      label: '货主',
      minWidth: 140,
      showOverflowTooltip: true,
      formatter: (row) => row.shipperName || '--'
    },
    {
      prop: 'supplierName',
      label: '供应商',
      minWidth: 140,
      showOverflowTooltip: true,
      formatter: (row) => row.supplierName || '--'
    },
    {
      prop: 'flagMixText',
      label: '支持混放',
      width: 100,
      align: 'center',
      formatter: (row) => row.flagMixText || '--'
    },
    {
      prop: 'flagMinusText',
      label: '允许负库存',
      width: 100,
      align: 'center',
      formatter: (row) => row.flagMinusText || '--'
    },
    {
      prop: 'flagLabelMangeText',
      label: '标签管理',
      width: 100,
      align: 'center',
      formatter: (row) => row.flagLabelMangeText || '--'
    },
    {
      prop: 'status',
      label: '状态',
      width: 100,
      align: 'center',
      formatter: (row) => {
        const statusMeta = getWarehouseAreasStatusMeta(row.statusBool ?? row.status)
        return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text)
      }
    },
    {
      prop: 'sort',
      label: '排序',
      width: 80,
      align: 'center',
      formatter: (row) => row.sort ?? '--'
    },
    {
      prop: 'memo',
      label: '备注',
      minWidth: 180,
      showOverflowTooltip: true,
      formatter: (row) => row.memo || '--'
    },
    {
      prop: 'updateTimeText',
      label: '更新时间',
      minWidth: 170,
      showOverflowTooltip: true,
      formatter: (row) => row.updateTimeText || '--'
    },
    {
      prop: 'operation',
      label: '操作',
      width: 160,
      align: 'right',
      formatter: (row) =>
        h(ArtButtonMore, {
          list: operations,
          onClick: (item) => {
            if (item.key === 'view') handleView?.(row)
            if (item.key === 'edit') handleEdit?.(row)
            if (item.key === 'delete') handleDelete?.(row)
          }
        })
    }
  ]
}
rsf-design/tests/basic-info-warehouse-areas-page-contract.test.mjs
New file
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
const apiPath = new URL('../src/api/warehouse-areas.js', import.meta.url)
const helpersPath = new URL('../src/views/basic-info/warehouse-areas/warehouseAreasPage.helpers.js', import.meta.url)
const columnsPath = new URL('../src/views/basic-info/warehouse-areas/warehouseAreasTable.columns.js', import.meta.url)
const pagePath = new URL('../src/views/basic-info/warehouse-areas/index.vue', import.meta.url)
const backendMenuAdapterPath = new URL('../src/router/adapters/backendMenuAdapter.js', import.meta.url)
const staticRoutesPath = new URL('../src/router/routes/staticRoutes.js', import.meta.url)
test('warehouse areas api uses real backend endpoints', async () => {
  const apiSource = await readFile(fileURLToPath(apiPath), 'utf8')
  assert.match(apiSource, /fetchWarehouseAreasPage/)
  assert.match(apiSource, /fetchWarehouseAreasDetail/)
  assert.match(apiSource, /fetchWarehouseAreasMany/)
  assert.match(apiSource, /fetchSaveWarehouseAreas/)
  assert.match(apiSource, /fetchUpdateWarehouseAreas/)
  assert.match(apiSource, /fetchDeleteWarehouseAreas/)
  assert.match(apiSource, /fetchWarehouseAreasQuery/)
  assert.match(apiSource, /fetchExportWarehouseAreasReport/)
  assert.match(apiSource, /url: '\/warehouseAreas\/page'/)
  assert.match(apiSource, /url: '\/warehouseAreas\/list'/)
  assert.match(apiSource, /url: `\/warehouseAreas\/many\/\$\{normalizeIds\(ids\)\}`/)
  assert.match(apiSource, /url: '\/warehouseAreas\/save'/)
  assert.match(apiSource, /url: '\/warehouseAreas\/update'/)
  assert.match(apiSource, /url: `\/warehouseAreas\/remove\/\$\{normalizeIds\(ids\)\}`/)
  assert.match(apiSource, /url: '\/warehouseAreas\/query'/)
  assert.match(apiSource, /warehouseAreas\/export/)
})
test('warehouse areas helpers keep page, save and detail contracts stable', async () => {
  const helpers = await import('../src/views/basic-info/warehouse-areas/warehouseAreasPage.helpers.js')
  assert.deepEqual(helpers.createWarehouseAreasSearchState(), {
    condition: '',
    warehouseId: '',
    code: '',
    name: '',
    type: '',
    shipperId: '',
    supplierId: '',
    status: '',
    memo: ''
  })
  assert.deepEqual(helpers.getWarehouseAreasPaginationKey(), {
    current: 'current',
    size: 'pageSize'
  })
  assert.deepEqual(
    helpers.buildWarehouseAreasPageQueryParams({
      current: 2,
      pageSize: 30,
      condition: '  库区A  ',
      warehouseId: 8,
      code: '  A01 ',
      name: '  一楼库区 ',
      type: '  normal ',
      shipperId: 5,
      supplierId: 7,
      status: 1,
      memo: '  memo  '
    }),
    {
      current: 2,
      pageSize: 30,
      condition: '库区A',
      warehouseId: 8,
      code: 'A01',
      name: '一楼库区',
      type: 'normal',
      shipperId: 5,
      supplierId: 7,
      status: 1,
      memo: 'memo'
    }
  )
  assert.deepEqual(
    helpers.buildWarehouseAreasSavePayload({
      id: '9',
      warehouseId: '3',
      code: ' A01 ',
      name: ' 一楼库区 ',
      type: ' normal ',
      shipperId: '11',
      supplierId: '12',
      flagMinus: 1,
      flagLabelMange: 0,
      flagMix: 1,
      status: '',
      sort: '2',
      memo: ' memo '
    }),
    {
      id: 9,
      warehouseId: 3,
      code: 'A01',
      name: '一楼库区',
      type: 'normal',
      shipperId: 11,
      supplierId: 12,
      flagMinus: 1,
      flagLabelMange: 0,
      flagMix: 1,
      status: 1,
      sort: 2,
      memo: 'memo'
    }
  )
  const detail = helpers.normalizeWarehouseAreasDetailRecord({
    id: 1,
    warehouseId: 4,
    warehouseId$: '主仓',
    type: 'A',
    type$: '常温',
    name: ' 一楼库区 ',
    code: ' A01 ',
    shipperId$: '货主A',
    supplierId$: '供应商B',
    flagMinus: 1,
    flagLabelMange: 0,
    flagMix: 1,
    status: 1,
    sort: 3,
    memo: ' memo ',
    createBy$: 'root',
    createTime$: '2026-03-30 10:00:00',
    updateBy$: 'root',
    updateTime$: '2026-03-30 10:10:00'
  })
  assert.equal(detail.statusText, '正常')
  assert.equal(detail.flagMixText, '是')
  assert.equal(detail.warehouseName, '主仓')
  assert.equal(detail.typeText, '常温')
  assert.equal(detail.shipperName, '货主A')
  assert.equal(detail.supplierName, '供应商B')
  assert.equal(detail.memo, 'memo')
})
test('warehouse areas columns expose detail action slot and status tag', async () => {
  const columnsSource = await readFile(fileURLToPath(columnsPath), 'utf8')
  assert.match(columnsSource, /createWarehouseAreasTableColumns/)
  assert.match(columnsSource, /ArtButtonMore|ArtButtonTable/)
  assert.match(columnsSource, /label: '操作'/)
  assert.match(columnsSource, /useSlot: true|formatter:/)
  assert.match(columnsSource, /label: '状态'/)
})
test('warehouse areas page uses real query, tree-like references and detail drawer structure', async () => {
  const pageSource = await readFile(fileURLToPath(pagePath), 'utf8')
  assert.match(pageSource, /fetchWarehouseAreasPage/)
  assert.match(pageSource, /fetchWarehouseAreasDetail/)
  assert.match(pageSource, /fetchSaveWarehouseAreas/)
  assert.match(pageSource, /fetchUpdateWarehouseAreas/)
  assert.match(pageSource, /fetchDeleteWarehouseAreas/)
  assert.match(pageSource, /WarehouseAreasDetailDrawer/)
  assert.match(pageSource, /ArtSearchBar/)
  assert.match(pageSource, /ArtTable/)
})
test('backend menu adapter releases warehouse areas route and static route is registered', async () => {
  const backendMenuAdapterSource = await readFile(fileURLToPath(backendMenuAdapterPath), 'utf8')
  const staticRoutesSource = await readFile(fileURLToPath(staticRoutesPath), 'utf8')
  assert.match(backendMenuAdapterSource, /warehouseAreas:\s*'\/basic-info\/warehouse-areas'/)
  assert.match(staticRoutesSource, /path: 'warehouse-areas'/)
  assert.match(staticRoutesSource, /title:\s*'menu\.warehouseAreas'/)
})