From 6e042a90361bb68e7a641af3aea30f9bea7716cf Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期一, 30 三月 2026 17:45:18 +0800
Subject: [PATCH] feat: add loc basic info page

---
 rsf-design/src/router/adapters/backendMenuAdapter.js              |    1 
 rsf-design/src/views/basic-info/loc/modules/loc-detail-drawer.vue |   80 ++
 rsf-design/src/views/basic-info/loc/modules/loc-dialog.vue        |  378 ++++++++++
 rsf-design/src/views/basic-info/loc/locTable.columns.js           |  153 ++++
 rsf-design/src/router/routes/staticRoutes.js                      |   10 
 rsf-design/src/api/loc.js                                         |  248 +++++++
 rsf-design/tests/basic-info-loc-page-contract.test.mjs            |  266 +++++++
 rsf-design/src/views/basic-info/loc/index.vue                     |  437 ++++++++++++
 rsf-design/src/views/basic-info/loc/locPage.helpers.js            |  477 +++++++++++++
 9 files changed, 2,050 insertions(+), 0 deletions(-)

diff --git a/rsf-design/src/api/loc.js b/rsf-design/src/api/loc.js
new file mode 100644
index 0000000..4213715
--- /dev/null
+++ b/rsf-design/src/api/loc.js
@@ -0,0 +1,248 @@
+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 normalizeNumber(value, fallback = void 0) {
+  if (value === '' || value === null || value === undefined) {
+    return fallback
+  }
+  const parsed = Number(value)
+  return Number.isNaN(parsed) ? fallback : parsed
+}
+
+function normalizeTypeIds(typeIds = []) {
+  if (Array.isArray(typeIds)) {
+    return typeIds
+      .map((item) => normalizeNumber(item, void 0))
+      .filter((item) => item !== void 0 && item !== null)
+  }
+
+  if (typeof typeIds === 'string' && typeIds.trim()) {
+    return typeIds
+      .split(',')
+      .map((item) => normalizeNumber(item, void 0))
+      .filter((item) => item !== void 0 && item !== null)
+  }
+
+  return []
+}
+
+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 buildLocPageParams(params = {}) {
+  return {
+    current: params.current || 1,
+    pageSize: params.pageSize || params.size || 20,
+    ...filterParams(params, ['current', 'pageSize', 'size'])
+  }
+}
+
+export function buildLocSearchParams(params = {}) {
+  const searchParams = {
+    condition: normalizeText(params.condition),
+    warehouseId:
+      params.warehouseId !== undefined && params.warehouseId !== null && params.warehouseId !== ''
+        ? Number(params.warehouseId)
+        : void 0,
+    areaId:
+      params.areaId !== undefined && params.areaId !== null && params.areaId !== ''
+        ? Number(params.areaId)
+        : void 0,
+    code: normalizeText(params.code),
+    useStatus: normalizeText(params.useStatus),
+    row:
+      params.row !== undefined && params.row !== null && params.row !== ''
+        ? Number(params.row)
+        : void 0,
+    col:
+      params.col !== undefined && params.col !== null && params.col !== ''
+        ? Number(params.col)
+        : void 0,
+    lev:
+      params.lev !== undefined && params.lev !== null && params.lev !== ''
+        ? Number(params.lev)
+        : void 0,
+    channel:
+      params.channel !== undefined && params.channel !== null && params.channel !== ''
+        ? Number(params.channel)
+        : void 0,
+    status:
+      params.status !== undefined && params.status !== null && params.status !== ''
+        ? Number(params.status)
+        : void 0,
+    barcode: normalizeText(params.barcode),
+    memo: normalizeText(params.memo)
+  }
+
+  return Object.fromEntries(
+    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
+  )
+}
+
+export function buildLocSavePayload(formData = {}) {
+  return {
+    ...(formData.id !== undefined && formData.id !== null && formData.id !== ''
+      ? { id: Number(formData.id) }
+      : {}),
+    ...(formData.version !== undefined && formData.version !== null && formData.version !== ''
+      ? { version: Number(formData.version) }
+      : {}),
+    ...(formData.warehouseId !== undefined && formData.warehouseId !== null && formData.warehouseId !== ''
+      ? { warehouseId: Number(formData.warehouseId) }
+      : {}),
+    ...(formData.areaId !== undefined && formData.areaId !== null && formData.areaId !== ''
+      ? { areaId: Number(formData.areaId) }
+      : {}),
+    code: normalizeText(formData.code) || '',
+    typeIds: normalizeTypeIds(formData.typeIds),
+    ...(formData.flagLogic !== undefined && formData.flagLogic !== null && formData.flagLogic !== ''
+      ? { flagLogic: Number(formData.flagLogic) }
+      : {}),
+    fucAtrrs: normalizeText(formData.fucAtrrs) || '',
+    barcode: normalizeText(formData.barcode) || '',
+    unit: normalizeText(formData.unit) || '',
+    ...(formData.length !== undefined && formData.length !== null && formData.length !== ''
+      ? { length: Number(formData.length) }
+      : {}),
+    ...(formData.height !== undefined && formData.height !== null && formData.height !== ''
+      ? { height: Number(formData.height) }
+      : {}),
+    ...(formData.width !== undefined && formData.width !== null && formData.width !== ''
+      ? { width: Number(formData.width) }
+      : {}),
+    ...(formData.row !== undefined && formData.row !== null && formData.row !== ''
+      ? { row: Number(formData.row) }
+      : {}),
+    ...(formData.col !== undefined && formData.col !== null && formData.col !== ''
+      ? { col: Number(formData.col) }
+      : {}),
+    ...(formData.lev !== undefined && formData.lev !== null && formData.lev !== ''
+      ? { lev: Number(formData.lev) }
+      : {}),
+    ...(formData.channel !== undefined && formData.channel !== null && formData.channel !== ''
+      ? { channel: Number(formData.channel) }
+      : {}),
+    ...(formData.maxParts !== undefined && formData.maxParts !== null && formData.maxParts !== ''
+      ? { maxParts: Number(formData.maxParts) }
+      : {}),
+    ...(formData.maxPack !== undefined && formData.maxPack !== null && formData.maxPack !== ''
+      ? { maxPack: Number(formData.maxPack) }
+      : {}),
+    useStatus: normalizeText(formData.useStatus) || 'O',
+    ...(formData.flagLabelMange !== undefined && formData.flagLabelMange !== null && formData.flagLabelMange !== ''
+      ? { flagLabelMange: Number(formData.flagLabelMange) }
+      : {}),
+    locAttrs: normalizeText(formData.locAttrs) || '',
+    status:
+      formData.status !== undefined && formData.status !== null && formData.status !== ''
+        ? Number(formData.status)
+        : 1,
+    memo: normalizeText(formData.memo) || ''
+  }
+}
+
+export function fetchLocPage(params = {}) {
+  return request.post({
+    url: '/loc/page',
+    params: buildLocPageParams(params)
+  })
+}
+
+export function fetchGetLocDetail(id) {
+  return request.get({
+    url: `/loc/${id}`
+  })
+}
+
+export function fetchGetLocMany(ids) {
+  return request.post({
+    url: `/loc/many/${normalizeIds(ids)}`
+  })
+}
+
+export function fetchSaveLoc(params = {}) {
+  return request.post({
+    url: '/loc/save',
+    params: buildLocSavePayload(params)
+  })
+}
+
+export function fetchUpdateLoc(params = {}) {
+  return request.post({
+    url: '/loc/update',
+    params: buildLocSavePayload(params)
+  })
+}
+
+export function fetchDeleteLoc(ids) {
+  return request.post({
+    url: `/loc/remove/${normalizeIds(ids)}`
+  })
+}
+
+export function fetchLocQuery(condition = '') {
+  return request.post({
+    url: '/loc/query',
+    params: {
+      condition: normalizeText(condition)
+    }
+  })
+}
+
+export function fetchLocTypeList() {
+  return request.post({
+    url: '/locType/list',
+    data: {}
+  })
+}
+
+export function fetchWarehouseList() {
+  return request.post({
+    url: '/warehouse/list',
+    data: {}
+  })
+}
+
+export function fetchWarehouseAreasList() {
+  return request.post({
+    url: '/warehouseAreas/list',
+    data: {}
+  })
+}
+
+export async function fetchExportLocReport(payload = {}, options = {}) {
+  return fetch(`${import.meta.env.VITE_API_URL}/loc/export`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      ...(options.headers || {})
+    },
+    body: JSON.stringify(payload)
+  })
+}
diff --git a/rsf-design/src/router/adapters/backendMenuAdapter.js b/rsf-design/src/router/adapters/backendMenuAdapter.js
index bc62f9d..cabcb5d 100644
--- a/rsf-design/src/router/adapters/backendMenuAdapter.js
+++ b/rsf-design/src/router/adapters/backendMenuAdapter.js
@@ -23,6 +23,7 @@
   basContainer: '/basic-info/bas-container',
   warehouse: '/basic-info/warehouse',
   warehouseAreas: '/basic-info/warehouse-areas',
+  loc: '/basic-info/loc',
   warehouseStock: '/stock/warehouse-stock',
   warehouseAreasItem: '/stock/warehouse-areas-item',
   qlyInspect: '/manager/qly-inspect',
diff --git a/rsf-design/src/router/routes/staticRoutes.js b/rsf-design/src/router/routes/staticRoutes.js
index 4988184..13cebd2 100644
--- a/rsf-design/src/router/routes/staticRoutes.js
+++ b/rsf-design/src/router/routes/staticRoutes.js
@@ -80,6 +80,16 @@
           icon: 'ri:layout-grid-line',
           keepAlive: false
         }
+      },
+      {
+        path: 'loc',
+        name: 'Loc',
+        component: () => import('@views/basic-info/loc/index.vue'),
+        meta: {
+          title: 'menu.loc',
+          icon: 'ri:map-pin-2-line',
+          keepAlive: false
+        }
       }
     ]
   },
diff --git a/rsf-design/src/views/basic-info/loc/index.vue b/rsf-design/src/views/basic-info/loc/index.vue
new file mode 100644
index 0000000..c4287c6
--- /dev/null
+++ b/rsf-design/src/views/basic-info/loc/index.vue
@@ -0,0 +1,437 @@
+<template>
+  <div class="loc-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>
+            <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="data"
+        :columns="columns"
+        :pagination="pagination"
+        @selection-change="handleSelectionChange"
+        @pagination:size-change="handleSizeChange"
+        @pagination:current-change="handleCurrentChange"
+      />
+
+      <LocDialog
+        v-model:visible="dialogVisible"
+        :dialog-type="dialogType"
+        :loc-data="currentLocData"
+        :warehouse-options="warehouseOptions"
+        :area-options="areaOptions"
+        :loc-type-options="locTypeOptions"
+        @submit="handleDialogSubmit"
+      />
+
+      <LocDetailDrawer
+        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 { useUserStore } from '@/store/modules/user'
+  import { useAuth } from '@/hooks/core/useAuth'
+  import { useTable } from '@/hooks/core/useTable'
+  import { useCrudPage } from '@/views/system/common/useCrudPage'
+  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
+  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
+  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
+  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
+  import { fetchWarehouseAreasList, fetchWarehouseList } from '@/api/warehouse-areas'
+  import {
+    fetchDeleteLoc,
+    fetchExportLocReport,
+    fetchGetLocDetail,
+    fetchGetLocMany,
+    fetchLocPage,
+    fetchLocTypeList,
+    fetchSaveLoc,
+    fetchUpdateLoc
+  } from '@/api/loc'
+  import LocDialog from './modules/loc-dialog.vue'
+  import LocDetailDrawer from './modules/loc-detail-drawer.vue'
+  import { createLocTableColumns } from './locTable.columns'
+  import {
+    buildLocDialogModel,
+    buildLocPageQueryParams,
+    buildLocPrintRows,
+    buildLocReportMeta,
+    buildLocSavePayload,
+    buildLocSearchParams,
+    createLocSearchState,
+    getLocPaginationKey,
+    getLocStatusOptions,
+    getLocUseStatusOptions,
+    normalizeLocListRow,
+    resolveLocAreaOptions,
+    resolveLocTypeOptions,
+    resolveLocWarehouseOptions,
+    LOC_REPORT_STYLE,
+    LOC_REPORT_TITLE
+  } from './locPage.helpers'
+
+  defineOptions({ name: 'Loc' })
+
+  const { hasAuth } = useAuth()
+  const userStore = useUserStore()
+
+  const searchForm = ref(createLocSearchState())
+  const detailDrawerVisible = ref(false)
+  const detailLoading = ref(false)
+  const detailData = ref({})
+  const warehouseOptions = ref([])
+  const areaOptions = ref([])
+  const locTypeOptions = ref([])
+  let handleDeleteAction = null
+
+  const reportTitle = LOC_REPORT_TITLE
+  const reportQueryParams = computed(() => buildLocSearchParams(searchForm.value))
+
+  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: 'areaId',
+      type: 'select',
+      props: {
+        clearable: true,
+        filterable: true,
+        options: areaOptions.value.filter((item) => {
+          if (!searchForm.value.warehouseId) {
+            return true
+          }
+          if (item?.warehouseId === undefined || item?.warehouseId === null) {
+            return true
+          }
+          return Number(item.warehouseId) === Number(searchForm.value.warehouseId)
+        })
+      }
+    },
+    {
+      label: '搴撲綅鍙�',
+      key: 'code',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ュ簱浣嶅彿'
+      }
+    },
+    {
+      label: '浣跨敤鐘舵��',
+      key: 'useStatus',
+      type: 'select',
+      props: {
+        clearable: true,
+        filterable: true,
+        options: getLocUseStatusOptions()
+      }
+    },
+    {
+      label: '鎺�',
+      key: 'row',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ユ帓'
+      }
+    },
+    {
+      label: '鍒�',
+      key: 'col',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ垪'
+      }
+    },
+    {
+      label: '灞�',
+      key: 'lev',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ眰'
+      }
+    },
+    {
+      label: '宸烽亾',
+      key: 'channel',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ贩閬�'
+      }
+    },
+    {
+      label: '鐘舵��',
+      key: 'status',
+      type: 'select',
+      props: {
+        clearable: true,
+        options: getLocStatusOptions()
+      }
+    },
+    {
+      label: '瀹瑰櫒缂栫爜',
+      key: 'barcode',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ュ鍣ㄧ紪鐮�'
+      }
+    },
+    {
+      label: '澶囨敞',
+      key: 'memo',
+      type: 'input',
+      props: {
+        clearable: true,
+        placeholder: '璇疯緭鍏ュ娉�'
+      }
+    }
+  ])
+
+  async function openDetail(row) {
+    detailDrawerVisible.value = true
+    detailLoading.value = true
+    try {
+      const detail = await guardRequestWithMessage(fetchGetLocDetail(row.id), {}, {
+        timeoutMessage: '搴撲綅璇︽儏鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟'
+      })
+      detailData.value = normalizeLocListRow(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(fetchGetLocDetail(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: fetchLocPage,
+      apiParams: buildLocPageQueryParams(searchForm.value),
+      paginationKey: getLocPaginationKey(),
+      columnsFactory: () =>
+        createLocTableColumns({
+          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) => normalizeLocListRow(item))
+      }
+    }
+  })
+
+  const {
+    dialogVisible,
+    dialogType,
+    currentRecord: currentLocData,
+    selectedRows,
+    handleSelectionChange,
+    showDialog,
+    handleDialogSubmit,
+    handleDelete,
+    handleBatchDelete
+  } = useCrudPage({
+    createEmptyModel: () => buildLocDialogModel(),
+    buildEditModel: (record) => buildLocDialogModel(record),
+    buildSavePayload: (formData) => buildLocSavePayload(formData),
+    saveRequest: fetchSaveLoc,
+    updateRequest: fetchUpdateLoc,
+    deleteRequest: fetchDeleteLoc,
+    entityName: '搴撲綅',
+    resolveRecordLabel: (record) => record?.code || record?.barcode || record?.id,
+    refreshCreate,
+    refreshUpdate,
+    refreshRemove
+  })
+  handleDeleteAction = handleDelete
+
+  const buildPreviewDialogMeta = (rows) => {
+    const now = new Date()
+    return {
+      reportTitle,
+      reportDate: now.toLocaleDateString('zh-CN'),
+      printedAt: now.toLocaleString('zh-CN', { hour12: false }),
+      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
+      count: rows.length
+    }
+  }
+
+  const resolvePrintRecords = async (payload) => {
+    const response = Array.isArray(payload?.ids) && payload.ids.length > 0
+      ? await fetchGetLocMany(payload.ids)
+      : await fetchLocPage({
+          ...reportQueryParams.value,
+          current: 1,
+          pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
+        })
+    return defaultResponseAdapter(response).records
+  }
+
+  const {
+    previewVisible,
+    previewRows,
+    previewMeta,
+    handlePreviewVisibleChange,
+    handleExport,
+    handlePrint
+  } = usePrintExportPage({
+    downloadFileName: 'loc.xlsx',
+    requestExport: (payload) =>
+      fetchExportLocReport(payload, {
+        headers: {
+          Authorization: userStore.accessToken || ''
+        }
+      }),
+    resolvePrintRecords,
+    buildPreviewRows: (records) => buildLocPrintRows(records),
+    buildPreviewMeta: (rows) => buildPreviewDialogMeta(rows)
+  })
+
+  const resolvedPreviewMeta = computed(() =>
+    buildLocReportMeta({
+      previewMeta: previewMeta.value,
+      count: previewRows.value.length,
+      titleAlign: LOC_REPORT_STYLE.titleAlign,
+      titleLevel: LOC_REPORT_STYLE.titleLevel
+    })
+  )
+
+  async function loadWarehouseOptions() {
+    const response = await guardRequestWithMessage(fetchWarehouseList(), [], {
+      timeoutMessage: '浠撳簱閫夐」鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟'
+    })
+    warehouseOptions.value = resolveLocWarehouseOptions(defaultResponseAdapter(response).records)
+  }
+
+  async function loadAreaOptions() {
+    const response = await guardRequestWithMessage(fetchWarehouseAreasList(), [], {
+      timeoutMessage: '搴撳尯閫夐」鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟'
+    })
+    areaOptions.value = resolveLocAreaOptions(defaultResponseAdapter(response).records)
+  }
+
+  async function loadLocTypeOptions() {
+    const response = await guardRequestWithMessage(fetchLocTypeList(), [], {
+      timeoutMessage: '搴撲綅绫诲瀷閫夐」鍔犺浇瓒呮椂锛屽凡鍋滄绛夊緟'
+    })
+    locTypeOptions.value = resolveLocTypeOptions(defaultResponseAdapter(response).records)
+  }
+
+  function handleSearch(params) {
+    replaceSearchParams(buildLocSearchParams(params))
+    getData()
+  }
+
+  function handleReset() {
+    Object.assign(searchForm.value, createLocSearchState())
+    resetSearchParams()
+  }
+
+  onMounted(async () => {
+    await Promise.all([loadWarehouseOptions(), loadAreaOptions(), loadLocTypeOptions()])
+  })
+</script>
diff --git a/rsf-design/src/views/basic-info/loc/locPage.helpers.js b/rsf-design/src/views/basic-info/loc/locPage.helpers.js
new file mode 100644
index 0000000..333ddf8
--- /dev/null
+++ b/rsf-design/src/views/basic-info/loc/locPage.helpers.js
@@ -0,0 +1,477 @@
+const STATUS_META = {
+  1: { text: '姝e父', type: 'success', bool: true },
+  0: { text: '鍐荤粨', type: 'danger', bool: false }
+}
+
+const USE_STATUS_META = {
+  O: { text: '绌哄簱', type: 'success' },
+  D: { text: '绌烘澘', type: 'info' },
+  R: { text: '棰勭害鍑哄簱', type: 'warning' },
+  S: { text: '棰勭害鍏ュ簱', type: 'warning' },
+  X: { text: '绂佺敤', type: 'danger' },
+  F: { text: '鍦ㄥ簱', type: 'primary' }
+}
+
+export const LOC_REPORT_TITLE = '搴撲綅鎶ヨ〃'
+export const LOC_REPORT_STYLE = {
+  titleAlign: 'center',
+  titleLevel: 'strong',
+  orientation: 'portrait',
+  density: 'compact',
+  showSequence: true
+}
+
+function normalizeText(value) {
+  return String(value ?? '').trim()
+}
+
+function normalizeNumber(value, fallback = void 0) {
+  if (value === '' || value === null || value === undefined) {
+    return fallback
+  }
+  const parsed = Number(value)
+  return Number.isNaN(parsed) ? fallback : parsed
+}
+
+function normalizeFlagText(value) {
+  if (value === 1 || value === '1' || value === true || value === '鏄�') {
+    return '鏄�'
+  }
+  if (value === 0 || value === '0' || value === false || value === '鍚�') {
+    return '鍚�'
+  }
+  return normalizeText(value) || '--'
+}
+
+function normalizeTypeIds(typeIds = []) {
+  if (Array.isArray(typeIds)) {
+    return typeIds
+      .map((item) => normalizeNumber(item, void 0))
+      .filter((item) => item !== void 0 && item !== null)
+  }
+
+  if (typeof typeIds === 'string' && typeIds.trim()) {
+    return typeIds
+      .split(',')
+      .map((item) => normalizeNumber(item, void 0))
+      .filter((item) => item !== void 0 && item !== null)
+  }
+
+  return []
+}
+
+export function createLocSearchState() {
+  return {
+    condition: '',
+    warehouseId: '',
+    areaId: '',
+    code: '',
+    useStatus: '',
+    row: '',
+    col: '',
+    lev: '',
+    channel: '',
+    status: '',
+    barcode: '',
+    memo: ''
+  }
+}
+
+export function createLocFormState() {
+  return {
+    id: void 0,
+    version: void 0,
+    warehouseId: void 0,
+    areaId: void 0,
+    code: '',
+    typeIds: [],
+    flagLogic: 0,
+    fucAtrrs: '',
+    barcode: '',
+    unit: '',
+    length: void 0,
+    height: void 0,
+    width: void 0,
+    row: void 0,
+    col: void 0,
+    lev: void 0,
+    channel: void 0,
+    maxParts: void 0,
+    maxPack: void 0,
+    useStatus: 'O',
+    flagLabelMange: 0,
+    locAttrs: '',
+    status: 1,
+    memo: ''
+  }
+}
+
+export function getLocPaginationKey() {
+  return {
+    current: 'current',
+    size: 'pageSize'
+  }
+}
+
+export function getLocStatusOptions() {
+  return [
+    { label: '姝e父', value: 1 },
+    { label: '鍐荤粨', value: 0 }
+  ]
+}
+
+export function getLocUseStatusOptions() {
+  return [
+    { label: '绌哄簱', value: 'O' },
+    { label: '绌烘澘', value: 'D' },
+    { label: '棰勭害鍑哄簱', value: 'R' },
+    { label: '棰勭害鍏ュ簱', value: 'S' },
+    { label: '绂佺敤', value: 'X' },
+    { label: '鍦ㄥ簱', value: 'F' }
+  ]
+}
+
+export function getLocBinaryOptions() {
+  return [
+    { label: '鍚�', value: 0 },
+    { label: '鏄�', value: 1 }
+  ]
+}
+
+export function getLocStatusMeta(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 getLocUseStatusMeta(useStatus) {
+  if (!useStatus) {
+    return { text: '鏈煡', type: 'info' }
+  }
+  return USE_STATUS_META[String(useStatus).trim()] || { text: String(useStatus), type: 'info' }
+}
+
+export function buildLocSearchParams(params = {}) {
+  const searchParams = {
+    condition: normalizeText(params.condition),
+    warehouseId:
+      params.warehouseId !== undefined && params.warehouseId !== null && params.warehouseId !== ''
+        ? Number(params.warehouseId)
+        : void 0,
+    areaId:
+      params.areaId !== undefined && params.areaId !== null && params.areaId !== ''
+        ? Number(params.areaId)
+        : void 0,
+    code: normalizeText(params.code),
+    useStatus: normalizeText(params.useStatus),
+    row:
+      params.row !== undefined && params.row !== null && params.row !== ''
+        ? Number(params.row)
+        : void 0,
+    col:
+      params.col !== undefined && params.col !== null && params.col !== ''
+        ? Number(params.col)
+        : void 0,
+    lev:
+      params.lev !== undefined && params.lev !== null && params.lev !== ''
+        ? Number(params.lev)
+        : void 0,
+    channel:
+      params.channel !== undefined && params.channel !== null && params.channel !== ''
+        ? Number(params.channel)
+        : void 0,
+    status:
+      params.status !== undefined && params.status !== null && params.status !== ''
+        ? Number(params.status)
+        : void 0,
+    barcode: normalizeText(params.barcode),
+    memo: normalizeText(params.memo)
+  }
+
+  return Object.fromEntries(
+    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
+  )
+}
+
+export function buildLocPageQueryParams(params = {}) {
+  return {
+    current: params.current || 1,
+    pageSize: params.pageSize || params.size || 20,
+    ...buildLocSearchParams(params)
+  }
+}
+
+export function buildLocSavePayload(formData = {}) {
+  return {
+    ...(formData.id !== undefined && formData.id !== null && formData.id !== ''
+      ? { id: Number(formData.id) }
+      : {}),
+    ...(formData.version !== undefined && formData.version !== null && formData.version !== ''
+      ? { version: Number(formData.version) }
+      : {}),
+    ...(formData.warehouseId !== undefined && formData.warehouseId !== null && formData.warehouseId !== ''
+      ? { warehouseId: Number(formData.warehouseId) }
+      : {}),
+    ...(formData.areaId !== undefined && formData.areaId !== null && formData.areaId !== ''
+      ? { areaId: Number(formData.areaId) }
+      : {}),
+    code: normalizeText(formData.code) || '',
+    typeIds: normalizeTypeIds(formData.typeIds),
+    ...(formData.flagLogic !== undefined && formData.flagLogic !== null && formData.flagLogic !== ''
+      ? { flagLogic: Number(formData.flagLogic) }
+      : {}),
+    fucAtrrs: normalizeText(formData.fucAtrrs) || '',
+    barcode: normalizeText(formData.barcode) || '',
+    unit: normalizeText(formData.unit) || '',
+    ...(formData.length !== undefined && formData.length !== null && formData.length !== ''
+      ? { length: Number(formData.length) }
+      : {}),
+    ...(formData.height !== undefined && formData.height !== null && formData.height !== ''
+      ? { height: Number(formData.height) }
+      : {}),
+    ...(formData.width !== undefined && formData.width !== null && formData.width !== ''
+      ? { width: Number(formData.width) }
+      : {}),
+    ...(formData.row !== undefined && formData.row !== null && formData.row !== ''
+      ? { row: Number(formData.row) }
+      : {}),
+    ...(formData.col !== undefined && formData.col !== null && formData.col !== ''
+      ? { col: Number(formData.col) }
+      : {}),
+    ...(formData.lev !== undefined && formData.lev !== null && formData.lev !== ''
+      ? { lev: Number(formData.lev) }
+      : {}),
+    ...(formData.channel !== undefined && formData.channel !== null && formData.channel !== ''
+      ? { channel: Number(formData.channel) }
+      : {}),
+    ...(formData.maxParts !== undefined && formData.maxParts !== null && formData.maxParts !== ''
+      ? { maxParts: Number(formData.maxParts) }
+      : {}),
+    ...(formData.maxPack !== undefined && formData.maxPack !== null && formData.maxPack !== ''
+      ? { maxPack: Number(formData.maxPack) }
+      : {}),
+    useStatus: normalizeText(formData.useStatus) || 'O',
+    ...(formData.flagLabelMange !== undefined && formData.flagLabelMange !== null && formData.flagLabelMange !== ''
+      ? { flagLabelMange: Number(formData.flagLabelMange) }
+      : {}),
+    locAttrs: normalizeText(formData.locAttrs) || '',
+    status:
+      formData.status !== undefined && formData.status !== null && formData.status !== ''
+        ? Number(formData.status)
+        : 1,
+    memo: normalizeText(formData.memo) || ''
+  }
+}
+
+export function buildLocDialogModel(record = {}) {
+  return {
+    ...createLocFormState(),
+    ...(record.id !== undefined && record.id !== null && record.id !== '' ? { id: Number(record.id) } : {}),
+    ...(record.version !== undefined && record.version !== null && record.version !== ''
+      ? { version: Number(record.version) }
+      : {}),
+    warehouseId:
+      record.warehouseId !== undefined && record.warehouseId !== null && record.warehouseId !== ''
+        ? Number(record.warehouseId)
+        : void 0,
+    areaId:
+      record.areaId !== undefined && record.areaId !== null && record.areaId !== ''
+        ? Number(record.areaId)
+        : void 0,
+    code: normalizeText(record.code || ''),
+    typeIds: normalizeTypeIds(record.typeIds ?? record.type ?? ''),
+    flagLogic:
+      record.flagLogic !== undefined && record.flagLogic !== null && record.flagLogic !== ''
+        ? Number(record.flagLogic)
+        : 0,
+    fucAtrrs: normalizeText(record.fucAtrrs || ''),
+    barcode: normalizeText(record.barcode || ''),
+    unit: normalizeText(record.unit || ''),
+    length:
+      record.length !== undefined && record.length !== null && record.length !== ''
+        ? Number(record.length)
+        : void 0,
+    height:
+      record.height !== undefined && record.height !== null && record.height !== ''
+        ? Number(record.height)
+        : void 0,
+    width:
+      record.width !== undefined && record.width !== null && record.width !== ''
+        ? Number(record.width)
+        : void 0,
+    row:
+      record.row !== undefined && record.row !== null && record.row !== ''
+        ? Number(record.row)
+        : void 0,
+    col:
+      record.col !== undefined && record.col !== null && record.col !== ''
+        ? Number(record.col)
+        : void 0,
+    lev:
+      record.lev !== undefined && record.lev !== null && record.lev !== ''
+        ? Number(record.lev)
+        : void 0,
+    channel:
+      record.channel !== undefined && record.channel !== null && record.channel !== ''
+        ? Number(record.channel)
+        : void 0,
+    maxParts:
+      record.maxParts !== undefined && record.maxParts !== null && record.maxParts !== ''
+        ? Number(record.maxParts)
+        : void 0,
+    maxPack:
+      record.maxPack !== undefined && record.maxPack !== null && record.maxPack !== ''
+        ? Number(record.maxPack)
+        : void 0,
+    useStatus: normalizeText(record.useStatus || 'O') || 'O',
+    flagLabelMange:
+      record.flagLabelMange !== undefined && record.flagLabelMange !== null && record.flagLabelMange !== ''
+        ? Number(record.flagLabelMange)
+        : 0,
+    locAttrs: normalizeText(record.locAttrs || ''),
+    status: record.status !== undefined && record.status !== null ? Number(record.status) : 1,
+    memo: normalizeText(record.memo || '')
+  }
+}
+
+export function normalizeLocDetailRecord(record = {}) {
+  const statusMeta = getLocStatusMeta(record.statusBool ?? record.status)
+  const useStatusMeta = getLocUseStatusMeta(record.useStatus)
+  const typeIds = normalizeTypeIds(record.typeIds ?? record.type ?? '')
+  return {
+    ...record,
+    warehouseName: normalizeText(record.warehouseId$ || record.warehouseName || ''),
+    areaName: normalizeText(record.areaId$ || record.areaName || ''),
+    typeIds,
+    typeIdsText: normalizeText(record.typeIds$ || record.type$ || record.typeText || record.type || ''),
+    code: normalizeText(record.code || ''),
+    barcode: normalizeText(record.barcode || ''),
+    unit: normalizeText(record.unit || ''),
+    fucAtrrs: normalizeText(record.fucAtrrs || ''),
+    locAttrs: normalizeText(record.locAttrs || ''),
+    memo: normalizeText(record.memo || ''),
+    flagLogicText: normalizeFlagText(record.flagLogic),
+    flagLabelMangeText: normalizeFlagText(record.flagLabelMange),
+    useStatusText: useStatusMeta.text,
+    useStatusType: useStatusMeta.type,
+    statusText: statusMeta.text,
+    statusType: statusMeta.type,
+    statusBool: record.statusBool !== void 0 ? Boolean(record.statusBool) : statusMeta.bool,
+    row: record.row !== undefined && record.row !== null ? Number(record.row) : void 0,
+    col: record.col !== undefined && record.col !== null ? Number(record.col) : void 0,
+    lev: record.lev !== undefined && record.lev !== null ? Number(record.lev) : void 0,
+    channel: record.channel !== undefined && record.channel !== null ? Number(record.channel) : void 0,
+    length: record.length !== undefined && record.length !== null ? Number(record.length) : void 0,
+    height: record.height !== undefined && record.height !== null ? Number(record.height) : void 0,
+    width: record.width !== undefined && record.width !== null ? Number(record.width) : void 0,
+    maxParts: record.maxParts !== undefined && record.maxParts !== null ? Number(record.maxParts) : void 0,
+    maxPack: record.maxPack !== undefined && record.maxPack !== null ? Number(record.maxPack) : void 0,
+    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 normalizeLocListRow(record = {}) {
+  return normalizeLocDetailRecord(record)
+}
+
+export function buildLocPrintRows(records = []) {
+  if (!Array.isArray(records)) {
+    return []
+  }
+  return records.map((record) => normalizeLocListRow(record))
+}
+
+export function buildLocReportMeta({
+  previewMeta = {},
+  count = 0,
+  orientation = LOC_REPORT_STYLE.orientation
+} = {}) {
+  return {
+    reportTitle: LOC_REPORT_TITLE,
+    reportDate: previewMeta.reportDate,
+    printedAt: previewMeta.printedAt,
+    operator: previewMeta.operator,
+    count,
+    reportStyle: {
+      ...LOC_REPORT_STYLE,
+      orientation
+    }
+  }
+}
+
+export function resolveLocWarehouseOptions(records = []) {
+  if (!Array.isArray(records)) {
+    return []
+  }
+
+  return records
+    .map((item) => {
+      if (!item || typeof item !== 'object') {
+        return null
+      }
+      const value = item.id ?? item.value
+      if (value === void 0 || value === null || value === '') {
+        return null
+      }
+      return {
+        value: Number(value),
+        label: normalizeText(item.name || item.code || `浠撳簱 ${value}`)
+      }
+    })
+    .filter(Boolean)
+}
+
+export function resolveLocAreaOptions(records = []) {
+  if (!Array.isArray(records)) {
+    return []
+  }
+
+  return records
+    .map((item) => {
+      if (!item || typeof item !== 'object') {
+        return null
+      }
+      const value = item.id ?? item.value
+      if (value === void 0 || value === null || value === '') {
+        return null
+      }
+      return {
+        value: Number(value),
+        label: normalizeText(item.name || item.code || `搴撳尯 ${value}`),
+        warehouseId:
+          item.warehouseId !== undefined && item.warehouseId !== null && item.warehouseId !== ''
+            ? Number(item.warehouseId)
+            : void 0
+      }
+    })
+    .filter(Boolean)
+}
+
+export function resolveLocTypeOptions(records = []) {
+  if (!Array.isArray(records)) {
+    return []
+  }
+
+  return records
+    .map((item) => {
+      if (!item || typeof item !== 'object') {
+        return null
+      }
+      const value = item.id ?? item.value
+      if (value === void 0 || value === null || value === '') {
+        return null
+      }
+      return {
+        value: Number(value),
+        label: normalizeText(item.name || item.code || item.label || `绫诲瀷 ${value}`)
+      }
+    })
+    .filter(Boolean)
+}
diff --git a/rsf-design/src/views/basic-info/loc/locTable.columns.js b/rsf-design/src/views/basic-info/loc/locTable.columns.js
new file mode 100644
index 0000000..5817307
--- /dev/null
+++ b/rsf-design/src/views/basic-info/loc/locTable.columns.js
@@ -0,0 +1,153 @@
+import { h } from 'vue'
+import { ElTag } from 'element-plus'
+import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue'
+import { getLocStatusMeta, getLocUseStatusMeta } from './locPage.helpers'
+
+export function createLocTableColumns({
+  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: 'code',
+      label: '搴撲綅鍙�',
+      minWidth: 160,
+      showOverflowTooltip: true,
+      formatter: (row) => row.code || '--'
+    },
+    {
+      prop: 'warehouseName',
+      label: '浠撳簱',
+      minWidth: 150,
+      showOverflowTooltip: true,
+      formatter: (row) => row.warehouseName || row.warehouseId$ || '--'
+    },
+    {
+      prop: 'areaName',
+      label: '搴撳尯',
+      minWidth: 150,
+      showOverflowTooltip: true,
+      formatter: (row) => row.areaName || row.areaId$ || '--'
+    },
+    {
+      prop: 'typeIdsText',
+      label: '搴撲綅绫诲瀷',
+      minWidth: 180,
+      showOverflowTooltip: true,
+      formatter: (row) => row.typeIdsText || '--'
+    },
+    {
+      prop: 'row',
+      label: '鎺�',
+      width: 80,
+      align: 'center',
+      formatter: (row) => row.row ?? '--'
+    },
+    {
+      prop: 'col',
+      label: '鍒�',
+      width: 80,
+      align: 'center',
+      formatter: (row) => row.col ?? '--'
+    },
+    {
+      prop: 'lev',
+      label: '灞�',
+      width: 80,
+      align: 'center',
+      formatter: (row) => row.lev ?? '--'
+    },
+    {
+      prop: 'channel',
+      label: '宸烽亾',
+      width: 90,
+      align: 'center',
+      formatter: (row) => row.channel ?? '--'
+    },
+    {
+      prop: 'useStatus',
+      label: '浣跨敤鐘舵��',
+      width: 110,
+      align: 'center',
+      formatter: (row) => {
+        const statusMeta = getLocUseStatusMeta(row.useStatus)
+        return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text)
+      }
+    },
+    {
+      prop: 'flagLogicText',
+      label: '铏氭嫙搴撲綅',
+      width: 110,
+      align: 'center',
+      formatter: (row) => row.flagLogicText || '--'
+    },
+    {
+      prop: 'flagLabelMangeText',
+      label: '鏍囩绠$悊',
+      width: 110,
+      align: 'center',
+      formatter: (row) => row.flagLabelMangeText || '--'
+    },
+    {
+      prop: 'barcode',
+      label: '瀹瑰櫒缂栫爜',
+      minWidth: 150,
+      showOverflowTooltip: true,
+      formatter: (row) => row.barcode || '--'
+    },
+    {
+      prop: 'status',
+      label: '鐘舵��',
+      width: 100,
+      align: 'center',
+      formatter: (row) => {
+        const statusMeta = getLocStatusMeta(row.statusBool ?? row.status)
+        return h(ElTag, { type: statusMeta.type, effect: 'light' }, () => statusMeta.text)
+      }
+    },
+    {
+      prop: 'updateTimeText',
+      label: '鏇存柊鏃堕棿',
+      minWidth: 170,
+      showOverflowTooltip: true,
+      formatter: (row) => row.updateTimeText || row.updateTime$ || '--'
+    },
+    {
+      prop: 'memo',
+      label: '澶囨敞',
+      minWidth: 180,
+      showOverflowTooltip: true,
+      formatter: (row) => row.memo || '--'
+    },
+    {
+      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)
+          }
+        })
+    }
+  ]
+}
diff --git a/rsf-design/src/views/basic-info/loc/modules/loc-detail-drawer.vue b/rsf-design/src/views/basic-info/loc/modules/loc-detail-drawer.vue
new file mode 100644
index 0000000..24d1c24
--- /dev/null
+++ b/rsf-design/src/views/basic-info/loc/modules/loc-detail-drawer.vue
@@ -0,0 +1,80 @@
+<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="14" animated />
+      </div>
+      <div v-else class="space-y-4">
+        <ElDescriptions title="鍩虹淇℃伅" :column="2" border>
+          <ElDescriptionsItem label="浠撳簱">{{ detail.warehouseName || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="搴撳尯">{{ detail.areaName || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="搴撲綅鍙�">{{ detail.code || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="搴撲綅绫诲瀷">{{ detail.typeIdsText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="浣跨敤鐘舵��">
+            <ElTag :type="detail.useStatusType || 'info'" effect="light">
+              {{ detail.useStatusText || '--' }}
+            </ElTag>
+          </ElDescriptionsItem>
+          <ElDescriptionsItem label="鐘舵��">
+            <ElTag :type="detail.statusType || 'info'" effect="light">
+              {{ detail.statusText || '--' }}
+            </ElTag>
+          </ElDescriptionsItem>
+          <ElDescriptionsItem label="瀹瑰櫒缂栫爜">{{ detail.barcode || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="瀛樻斁鍗曚綅">{{ detail.unit || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="澶囨敞" :span="2">{{ detail.memo || '--' }}</ElDescriptionsItem>
+        </ElDescriptions>
+
+        <ElDescriptions title="浣嶇疆淇℃伅" :column="2" border>
+          <ElDescriptionsItem label="鎺�">{{ detail.row ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍒�">{{ detail.col ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="灞�">{{ detail.lev ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="宸烽亾">{{ detail.channel ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="铏氭嫙搴撲綅">{{ detail.flagLogicText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏍囩绠$悊">{{ detail.flagLabelMangeText || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="闀�">{{ detail.length ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="瀹�">{{ detail.width ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="楂�">{{ detail.height ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏈�澶ч浂浠舵暟">{{ detail.maxParts ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鏈�澶у寘瑁呮暟">{{ detail.maxPack ?? '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="鍔熻兘灞炴��" :span="2">{{ detail.fucAtrrs || '--' }}</ElDescriptionsItem>
+          <ElDescriptionsItem label="灞炴��" :span="2">{{ detail.locAttrs || '--' }}</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>
diff --git a/rsf-design/src/views/basic-info/loc/modules/loc-dialog.vue b/rsf-design/src/views/basic-info/loc/modules/loc-dialog.vue
new file mode 100644
index 0000000..a3a88c2
--- /dev/null
+++ b/rsf-design/src/views/basic-info/loc/modules/loc-dialog.vue
@@ -0,0 +1,378 @@
+<template>
+  <ElDialog
+    :title="dialogTitle"
+    :model-value="visible"
+    width="980px"
+    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 {
+    buildLocDialogModel,
+    createLocFormState,
+    getLocBinaryOptions,
+    getLocStatusOptions,
+    getLocUseStatusOptions
+  } from '../locPage.helpers'
+
+  const props = defineProps({
+    visible: { type: Boolean, default: false },
+    dialogType: { type: String, default: 'add' },
+    locData: { type: Object, default: () => ({}) },
+    warehouseOptions: { type: Array, default: () => [] },
+    areaOptions: { type: Array, default: () => [] },
+    locTypeOptions: { type: Array, default: () => [] }
+  })
+
+  const emit = defineEmits(['update:visible', 'submit'])
+  const formRef = ref()
+  const form = reactive(createLocFormState())
+
+  const isEdit = computed(() => props.dialogType === 'edit')
+  const dialogTitle = computed(() => (isEdit.value ? '缂栬緫搴撲綅' : '鏂板搴撲綅'))
+
+  const filteredAreaOptions = computed(() => {
+    if (!form.warehouseId) {
+      return props.areaOptions
+    }
+    return (props.areaOptions || []).filter((item) => {
+      if (item?.warehouseId === undefined || item?.warehouseId === null) {
+        return true
+      }
+      return Number(item.warehouseId) === Number(form.warehouseId)
+    })
+  })
+
+  const rules = computed(() => ({
+    warehouseId: [{ required: true, message: '璇烽�夋嫨浠撳簱', trigger: 'change' }],
+    areaId: [{ required: true, message: '璇烽�夋嫨搴撳尯', trigger: 'change' }],
+    code: [{ required: true, message: '璇疯緭鍏ュ簱浣嶅彿', trigger: 'blur' }],
+    typeIds: [{ type: 'array', required: true, message: '璇烽�夋嫨搴撲綅绫诲瀷', trigger: 'change' }],
+    row: [{ required: true, message: '璇疯緭鍏ユ帓', trigger: 'change' }],
+    col: [{ required: true, message: '璇疯緭鍏ュ垪', trigger: 'change' }],
+    lev: [{ required: true, message: '璇疯緭鍏ュ眰', trigger: 'change' }],
+    useStatus: [{ required: true, message: '璇烽�夋嫨浣跨敤鐘舵��', trigger: 'change' }]
+  }))
+
+  const formItems = computed(() => [
+    {
+      label: '浠撳簱',
+      key: 'warehouseId',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨浠撳簱',
+        clearable: true,
+        filterable: true,
+        options: props.warehouseOptions
+      }
+    },
+    {
+      label: '搴撳尯',
+      key: 'areaId',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨搴撳尯',
+        clearable: true,
+        filterable: true,
+        options: filteredAreaOptions.value
+      }
+    },
+    {
+      label: '搴撲綅鍙�',
+      key: 'code',
+      type: 'input',
+      props: {
+        placeholder: '璇疯緭鍏ュ簱浣嶅彿',
+        clearable: true
+      }
+    },
+    {
+      label: '搴撲綅绫诲瀷',
+      key: 'typeIds',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨搴撲綅绫诲瀷',
+        clearable: true,
+        multiple: true,
+        collapseTags: true,
+        filterable: true,
+        options: props.locTypeOptions
+      }
+    },
+    {
+      label: '浣跨敤鐘舵��',
+      key: 'useStatus',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨浣跨敤鐘舵��',
+        clearable: true,
+        filterable: true,
+        options: getLocUseStatusOptions()
+      }
+    },
+    {
+      label: '鐘舵��',
+      key: 'status',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨鐘舵��',
+        clearable: true,
+        options: getLocStatusOptions()
+      }
+    },
+    {
+      label: '鎺�',
+      key: 'row',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ユ帓'
+      }
+    },
+    {
+      label: '鍒�',
+      key: 'col',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ垪'
+      }
+    },
+    {
+      label: '灞�',
+      key: 'lev',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ眰'
+      }
+    },
+    {
+      label: '宸烽亾',
+      key: 'channel',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ贩閬�'
+      }
+    },
+    {
+      label: '瀹瑰櫒缂栫爜',
+      key: 'barcode',
+      type: 'input',
+      props: {
+        placeholder: '璇疯緭鍏ュ鍣ㄧ紪鐮�',
+        clearable: true
+      }
+    },
+    {
+      label: '瀛樻斁鍗曚綅',
+      key: 'unit',
+      type: 'input',
+      props: {
+        placeholder: '璇疯緭鍏ュ瓨鏀惧崟浣�',
+        clearable: true
+      }
+    },
+    {
+      label: '铏氭嫙搴撲綅',
+      key: 'flagLogic',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨铏氭嫙搴撲綅',
+        clearable: true,
+        options: getLocBinaryOptions()
+      }
+    },
+    {
+      label: '鏍囩绠$悊',
+      key: 'flagLabelMange',
+      type: 'select',
+      props: {
+        placeholder: '璇烽�夋嫨鏍囩绠$悊',
+        clearable: true,
+        options: getLocBinaryOptions()
+      }
+    },
+    {
+      label: '闀�',
+      key: 'length',
+      type: 'number',
+      props: {
+        min: 0,
+        step: 0.01,
+        precision: 2,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ラ暱'
+      }
+    },
+    {
+      label: '瀹�',
+      key: 'width',
+      type: 'number',
+      props: {
+        min: 0,
+        step: 0.01,
+        precision: 2,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ュ'
+      }
+    },
+    {
+      label: '楂�',
+      key: 'height',
+      type: 'number',
+      props: {
+        min: 0,
+        step: 0.01,
+        precision: 2,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ラ珮'
+      }
+    },
+    {
+      label: '鏈�澶ч浂浠舵暟',
+      key: 'maxParts',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ユ渶澶ч浂浠舵暟'
+      }
+    },
+    {
+      label: '鏈�澶у寘瑁呮暟',
+      key: 'maxPack',
+      type: 'number',
+      props: {
+        min: 0,
+        controlsPosition: 'right',
+        placeholder: '璇疯緭鍏ユ渶澶у寘瑁呮暟'
+      }
+    },
+    {
+      label: '鍔熻兘灞炴��',
+      key: 'fucAtrrs',
+      type: 'input',
+      span: 24,
+      props: {
+        placeholder: '璇疯緭鍏ュ姛鑳藉睘鎬�',
+        clearable: true
+      }
+    },
+    {
+      label: '灞炴��',
+      key: 'locAttrs',
+      type: 'input',
+      span: 24,
+      props: {
+        placeholder: '璇疯緭鍏ュ睘鎬�',
+        clearable: true
+      }
+    },
+    {
+      label: '澶囨敞',
+      key: 'memo',
+      type: 'input',
+      span: 24,
+      props: {
+        type: 'textarea',
+        rows: 3,
+        placeholder: '璇疯緭鍏ュ娉�',
+        clearable: true
+      }
+    }
+  ])
+
+  const loadFormData = () => {
+    Object.assign(form, buildLocDialogModel(props.locData))
+  }
+
+  const resetForm = () => {
+    Object.assign(form, createLocFormState())
+    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.locData,
+    () => {
+      if (props.visible) {
+        loadFormData()
+      }
+    },
+    { deep: true }
+  )
+
+  watch(
+    () => form.warehouseId,
+    () => {
+      if (!form.areaId) {
+        return
+      }
+      const available = filteredAreaOptions.value.some((item) => Number(item.value) === Number(form.areaId))
+      if (!available) {
+        form.areaId = void 0
+      }
+    }
+  )
+</script>
diff --git a/rsf-design/tests/basic-info-loc-page-contract.test.mjs b/rsf-design/tests/basic-info-loc-page-contract.test.mjs
new file mode 100644
index 0000000..a7f5dee
--- /dev/null
+++ b/rsf-design/tests/basic-info-loc-page-contract.test.mjs
@@ -0,0 +1,266 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+const pageModuleUrl = new URL('../src/views/basic-info/loc/index.vue', import.meta.url)
+const helpersModuleUrl = new URL('../src/views/basic-info/loc/locPage.helpers.js', import.meta.url)
+const columnsModuleUrl = new URL('../src/views/basic-info/loc/locTable.columns.js', import.meta.url)
+const apiModuleUrl = new URL('../src/api/loc.js', import.meta.url)
+const backendMenuAdapterUrl = new URL('../src/router/adapters/backendMenuAdapter.js', import.meta.url)
+const staticRoutesUrl = new URL('../src/router/routes/staticRoutes.js', import.meta.url)
+
+test('loc api exposes the dedicated basic-info backend contract', async () => {
+  const apiSource = readFileSync(apiModuleUrl, 'utf8')
+
+  assert.match(apiSource, /fetchLocPage/)
+  assert.match(apiSource, /fetchGetLocDetail/)
+  assert.match(apiSource, /fetchGetLocMany/)
+  assert.match(apiSource, /fetchSaveLoc/)
+  assert.match(apiSource, /fetchUpdateLoc/)
+  assert.match(apiSource, /fetchDeleteLoc/)
+  assert.match(apiSource, /fetchLocQuery/)
+  assert.match(apiSource, /fetchLocTypeList/)
+  assert.match(apiSource, /fetchWarehouseList/)
+  assert.match(apiSource, /fetchWarehouseAreasList/)
+  assert.match(apiSource, /fetchExportLocReport/)
+  assert.match(apiSource, /url:\s*'\/loc\/page'/)
+  assert.match(apiSource, /url:\s*'\/loc\/save'/)
+  assert.match(apiSource, /url:\s*'\/loc\/update'/)
+  assert.match(apiSource, /url:\s*`\/loc\/remove\/\$\{normalizeIds\(ids\)\}`/)
+  assert.match(apiSource, /url:\s*'\/locType\/list'/)
+})
+
+test('loc helpers keep page, save and detail contracts stable', async () => {
+  const helpers = await import(helpersModuleUrl)
+
+  assert.deepEqual(helpers.createLocSearchState(), {
+    condition: '',
+    warehouseId: '',
+    areaId: '',
+    code: '',
+    useStatus: '',
+    row: '',
+    col: '',
+    lev: '',
+    channel: '',
+    status: '',
+    barcode: '',
+    memo: ''
+  })
+
+  assert.deepEqual(helpers.getLocPaginationKey(), {
+    current: 'current',
+    size: 'pageSize'
+  })
+
+  assert.deepEqual(
+    helpers.buildLocPageQueryParams({
+      current: 2,
+      pageSize: 30,
+      condition: '  A鍖�  ',
+      warehouseId: '8',
+      areaId: '9',
+      code: ' LOC-01 ',
+      useStatus: ' O ',
+      row: '1',
+      col: '2',
+      lev: '3',
+      channel: '4',
+      status: '1',
+      barcode: ' BOX-01 ',
+      memo: '  memo  '
+    }),
+    {
+      current: 2,
+      pageSize: 30,
+      condition: 'A鍖�',
+      warehouseId: 8,
+      areaId: 9,
+      code: 'LOC-01',
+      useStatus: 'O',
+      row: 1,
+      col: 2,
+      lev: 3,
+      channel: 4,
+      status: 1,
+      barcode: 'BOX-01',
+      memo: 'memo'
+    }
+  )
+
+  assert.deepEqual(
+    helpers.buildLocSavePayload({
+      id: '8',
+      version: '3',
+      warehouseId: '5',
+      areaId: '6',
+      code: ' LOC-01 ',
+      typeIds: ['1', '2'],
+      flagLogic: 1,
+      fucAtrrs: ' 鍔熻兘 ',
+      barcode: ' BOX-01 ',
+      unit: ' 绠� ',
+      length: '1.2',
+      height: '2.3',
+      width: '3.4',
+      row: '1',
+      col: '2',
+      lev: '3',
+      channel: '4',
+      maxParts: '10',
+      maxPack: '20',
+      useStatus: 'F',
+      flagLabelMange: 0,
+      locAttrs: ' 灞炴�� ',
+      status: '',
+      memo: ' 澶囨敞 '
+    }),
+    {
+      id: 8,
+      version: 3,
+      warehouseId: 5,
+      areaId: 6,
+      code: 'LOC-01',
+      typeIds: [1, 2],
+      flagLogic: 1,
+      fucAtrrs: '鍔熻兘',
+      barcode: 'BOX-01',
+      unit: '绠�',
+      length: 1.2,
+      height: 2.3,
+      width: 3.4,
+      row: 1,
+      col: 2,
+      lev: 3,
+      channel: 4,
+      maxParts: 10,
+      maxPack: 20,
+      useStatus: 'F',
+      flagLabelMange: 0,
+      locAttrs: '灞炴��',
+      status: 1,
+      memo: '澶囨敞'
+    }
+  )
+
+  const detail = helpers.normalizeLocDetailRecord({
+    id: 1,
+    warehouseId: 4,
+    warehouseId$: '涓讳粨',
+    areaId: 8,
+    areaId$: 'A鍖�',
+    type: '1,2',
+    typeIds$: '楂樺簱浣�,涓簱浣�',
+    useStatus: 'O',
+    status: 1,
+    flagLogic: 1,
+    flagLabelMange: 0,
+    code: ' LOC-01 ',
+    barcode: ' BOX-01 ',
+    unit: ' 绠� ',
+    row: 1,
+    col: 2,
+    lev: 3,
+    channel: 4,
+    length: 1.2,
+    height: 2.3,
+    width: 3.4,
+    maxParts: 10,
+    maxPack: 20,
+    memo: ' memo ',
+    createBy$: 'ROOT',
+    updateBy$: 'ROOT',
+    createTime$: '2026-03-30 10:00:00',
+    updateTime$: '2026-03-30 10:10:00'
+  })
+
+  assert.equal(detail.warehouseName, '涓讳粨')
+  assert.equal(detail.areaName, 'A鍖�')
+  assert.equal(detail.typeIdsText, '楂樺簱浣�,涓簱浣�')
+  assert.equal(detail.useStatusText, '绌哄簱')
+  assert.equal(detail.statusText, '姝e父')
+  assert.equal(detail.flagLogicText, '鏄�')
+  assert.equal(detail.flagLabelMangeText, '鍚�')
+  assert.equal(detail.memo, 'memo')
+
+  assert.deepEqual(helpers.buildLocPrintRows([{ id: 2, code: 'LOC-02', useStatus: 'F', status: 0 }]), [
+    {
+      id: 2,
+      code: 'LOC-02',
+      useStatus: 'F',
+      status: 0,
+      warehouseName: '',
+      areaName: '',
+      typeIds: [],
+      typeIdsText: '',
+      barcode: '',
+      unit: '',
+      fucAtrrs: '',
+      locAttrs: '',
+      memo: '',
+      flagLogicText: '--',
+      flagLabelMangeText: '--',
+      useStatusText: '鍦ㄥ簱',
+      useStatusType: 'primary',
+      statusText: '鍐荤粨',
+      statusType: 'danger',
+      statusBool: false,
+      row: void 0,
+      col: void 0,
+      lev: void 0,
+      channel: void 0,
+      length: void 0,
+      height: void 0,
+      width: void 0,
+      maxParts: void 0,
+      maxPack: void 0,
+      createByText: '',
+      createTimeText: '',
+      updateByText: '',
+      updateTimeText: ''
+    }
+  ])
+
+  assert.deepEqual(helpers.buildLocReportMeta({ count: 12 }), {
+    reportTitle: '搴撲綅鎶ヨ〃',
+    reportDate: undefined,
+    printedAt: undefined,
+    operator: undefined,
+    count: 12,
+    reportStyle: {
+      titleAlign: 'center',
+      titleLevel: 'strong',
+      orientation: 'portrait',
+      density: 'compact',
+      showSequence: true
+    }
+  })
+})
+
+test('loc page skeleton uses ArtDesignPro list page structure', async () => {
+  const pageSource = readFileSync(pageModuleUrl, 'utf8')
+  const columnsSource = readFileSync(columnsModuleUrl, 'utf8')
+
+  assert.match(pageSource, /ArtSearchBar/)
+  assert.match(pageSource, /ArtTableHeader/)
+  assert.match(pageSource, /ListExportPrint/)
+  assert.match(pageSource, /LocDialog/)
+  assert.match(pageSource, /LocDetailDrawer/)
+  assert.match(pageSource, /useCrudPage/)
+  assert.match(pageSource, /usePrintExportPage/)
+  assert.match(columnsSource, /label:\s*'搴撲綅鍙�'/)
+  assert.match(columnsSource, /label:\s*'浠撳簱'/)
+  assert.match(columnsSource, /label:\s*'搴撳尯'/)
+  assert.match(columnsSource, /label:\s*'搴撲綅绫诲瀷'/)
+  assert.match(columnsSource, /label:\s*'浣跨敤鐘舵��'/)
+})
+
+test('backend menu adapter and static routes expose the loc page', async () => {
+  const backendMenuAdapterSource = readFileSync(backendMenuAdapterUrl, 'utf8')
+  const staticRoutesSource = readFileSync(staticRoutesUrl, 'utf8')
+
+  assert.match(backendMenuAdapterSource, /loc:\s*'\/basic-info\/loc'/)
+  assert.match(staticRoutesSource, /path:\s*'loc'/)
+  assert.match(staticRoutesSource, /title:\s*'menu\.loc'/)
+  assert.match(staticRoutesSource, /basic-info\/loc\/index\.vue/)
+})

--
Gitblit v1.9.1