#
zhou zhou
6 小时以前 333a93571452073a9e628c6256044d345099aa50
#
3个文件已添加
39个文件已修改
2583 ■■■■■ 已修改文件
rsf-design/src/api/bas-station-area.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/out-stock-item.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/task.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-page-content/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/main.js 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/bas-station-area/basStationAreaPage.helpers.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/bas-station-area/index.vue 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/task-path-template/modules/task-path-template-flow-drawer.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/warehouse-areas/index.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/task/index.vue 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/task/modules/task-detail-drawer.vue 51 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/task/modules/task-expand-panel.vue 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/task/modules/task-flow-step-dialog.vue 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/task/taskPage.helpers.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/manager/task/taskTable.columns.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/asn-order-item/asnOrderItemPage.helpers.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/asn-order-item/index.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/asn-order/asnOrderPage.helpers.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/asn-order/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/asn-order/modules/asn-order-detail-drawer.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/delivery-item/deliveryItemPage.helpers.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/delivery-item/index.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/delivery/deliveryPage.helpers.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/delivery/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/out-stock-item/index.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/out-stock-item/outStockItemPage.helpers.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/out-stock/index.vue 112 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/out-stock/modules/out-stock-detail-drawer.vue 46 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/out-stock/outStockPage.helpers.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/preparation-item/index.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/preparation/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/preparation/modules/preparation-detail-drawer.vue 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/preparation/preparationPage.helpers.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/purchase-item/index.vue 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/purchase/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/purchase/purchaseTable.columns.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/transfer-item/index.vue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/transfer/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/orders/transfer/transferPage.helpers.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/menu/menuTable.columns.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-framework/src/main/java/com/vincent/rsf/framework/generators/RsfDesignGenerator.java 38 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/test/java/com/vincent/rsf/server/common/RsfDesignGeneratorRegressionTest.java 503 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/bas-station-area.js
@@ -209,6 +209,17 @@
  })
}
export async function fetchExportBasStationAreaReport(payload = {}, options = {}) {
  return fetch(`${import.meta.env.VITE_API_URL}/basStationArea/export`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.headers || {})
    },
    body: JSON.stringify(payload)
  })
}
export function fetchBasStationAreaQuery(condition = '') {
  return request.post({
    url: '/basStationArea/query',
rsf-design/src/api/out-stock-item.js
@@ -48,6 +48,13 @@
    }
  }
  if (params.orderId !== '' && params.orderId !== undefined && params.orderId !== null) {
    const numericOrderId = Number(params.orderId)
    if (!Number.isNaN(numericOrderId)) {
      result.orderId = numericOrderId
    }
  }
  return result
}
rsf-design/src/api/task.js
@@ -93,3 +93,19 @@
    url: `/task/top/${id}`
  })
}
export function fetchTaskAutoRunFlag() {
  return request.get({
    url: '/config/flag/AUTO_RUN_CHECK_ORDERS'
  })
}
export function fetchUpdateTaskAutoRunFlag(enabled) {
  return request.post({
    url: '/config/byFlag',
    params: {
      val: !!enabled,
      flag: 'AUTO_RUN_CHECK_ORDERS'
    }
  })
}
rsf-design/src/components/core/layouts/art-page-content/index.vue
@@ -22,27 +22,21 @@
      </div>
    </div>
    <RouterView v-else-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
    <RouterView v-else-if="isRefresh" v-slot="{ Component, route }">
      <!-- 缓存路由动画 -->
      <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
        <div v-if="route.meta.keepAlive" class="art-page-view" :style="contentStyle">
        <KeepAlive :max="10" :exclude="keepAliveExclude">
          <component
            class="art-page-view"
            :is="Component"
            :key="route.path"
            v-if="route.meta.keepAlive"
          />
            <component :is="Component" :key="route.path" />
        </KeepAlive>
        </div>
      </Transition>
      <!-- 非缓存路由动画 -->
      <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
        <component
          class="art-page-view"
          :is="Component"
          :key="route.path"
          v-if="!route.meta.keepAlive"
        />
        <div v-if="!route.meta.keepAlive" class="art-page-view" :style="contentStyle">
          <component :is="Component" :key="route.path" />
        </div>
      </Transition>
    </RouterView>
rsf-design/src/main.js
@@ -13,21 +13,6 @@
registerLocalIconCollections()
const app = createApp(App)
// 注入错误日志面板用于调试
app.config.errorHandler = (err, vm, info) => {
  console.error("Vue Error:", err, info);
  const div = document.createElement("div");
  div.style = "position:fixed;top:0;left:0;z-index:99999;background:red;color:white;padding:20px;font-size:16px;white-space:pre-wrap;width:100vw;height:100vh;overflow:auto;";
  div.innerText = "Error: " + (err.message || err) + "\n\nStack:\n" + err.stack + "\n\nInfo: " + info;
  document.body.appendChild(div);
};
window.addEventListener("error", (event) => {
  const div = document.createElement("div");
  div.style = "position:fixed;top:0;left:0;z-index:99999;background:red;color:white;padding:20px;font-size:16px;white-space:pre-wrap;width:100vw;height:100vh;overflow:auto;";
  div.innerText = "Global Error: " + event.message + "\n\n" + event.error?.stack;
  document.body.appendChild(div);
});
initStore(app)
initRouter(app)
setupGlobDirectives(app)
rsf-design/src/views/basic-info/bas-station-area/basStationAreaPage.helpers.js
@@ -107,6 +107,8 @@
export function createBasStationAreaSearchState() {
  return {
    condition: '',
    timeStart: '',
    timeEnd: '',
    stationAreaName: '',
    stationAreaId: '',
    type: '',
@@ -260,6 +262,8 @@
export function buildBasStationAreaSearchParams(params = {}) {
  const searchParams = {
    condition: normalizeText(params.condition),
    timeStart: normalizeText(params.timeStart),
    timeEnd: normalizeText(params.timeEnd),
    stationAreaName: normalizeText(params.stationAreaName),
    stationAreaId: normalizeText(params.stationAreaId),
    type:
@@ -472,3 +476,28 @@
export function normalizeBasStationAreaListRow(record = {}, resolvers = {}) {
  return normalizeBasStationAreaDetailRecord(record, resolvers)
}
export function buildBasStationAreaPrintRows(records = [], resolvers = {}) {
  if (!Array.isArray(records)) {
    return []
  }
  return records.map((record) => normalizeBasStationAreaListRow(record, resolvers))
}
export function buildBasStationAreaReportMeta({
  previewMeta = {},
  count = 0,
  orientation = BAS_STATION_AREA_REPORT_STYLE.orientation
} = {}) {
  return {
    reportTitle: BAS_STATION_AREA_REPORT_TITLE,
    reportDate: previewMeta.reportDate,
    printedAt: previewMeta.printedAt,
    operator: previewMeta.operator,
    count,
    reportStyle: {
      ...BAS_STATION_AREA_REPORT_STYLE,
      orientation
    }
  }
}
rsf-design/src/views/basic-info/bas-station-area/index.vue
@@ -24,6 +24,21 @@
            >
              批量删除
            </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>
@@ -62,9 +77,12 @@
<script setup>
  import { computed, onMounted, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  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 { useUserStore } from '@/store/modules/user'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchDictDataPage } from '@/api/system-manage'
@@ -72,8 +90,10 @@
  import { fetchWarehouseAreasList } from '@/api/warehouse-areas'
  import {
    fetchBasStationAreaDetail,
    fetchBasStationAreaMany,
    fetchBasStationAreaPage,
    fetchDeleteBasStationArea,
    fetchExportBasStationAreaReport,
    fetchSaveBasStationArea,
    fetchUpdateBasStationArea
  } from '@/api/bas-station-area'
@@ -81,8 +101,12 @@
  import BasStationAreaDetailDrawer from './modules/bas-station-area-detail-drawer.vue'
  import { createBasStationAreaTableColumns } from './basStationAreaTable.columns'
  import {
    BAS_STATION_AREA_REPORT_STYLE,
    BAS_STATION_AREA_REPORT_TITLE,
    buildBasStationAreaDialogModel,
    buildBasStationAreaPageQueryParams,
    buildBasStationAreaPrintRows,
    buildBasStationAreaReportMeta,
    buildBasStationAreaSavePayload,
    buildBasStationAreaSearchParams,
    createBasStationAreaSearchState,
@@ -101,6 +125,7 @@
  defineOptions({ name: 'BasStationArea' })
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
  const searchForm = ref(createBasStationAreaSearchState())
  const detailDrawerVisible = ref(false)
@@ -162,6 +187,8 @@
  const resolveTypeLabel = (value) => typeLabelMap.value.get(String(value)) || ''
  const resolveStationAliasLabel = (id) => stationLabelMap.value.get(String(id)) || ''
  const resolveUseStatusLabel = (value) => useStatusLabelMap.value.get(String(value)) || ''
  const reportTitle = BAS_STATION_AREA_REPORT_TITLE
  const reportQueryParams = computed(() => buildBasStationAreaSearchParams(searchForm.value))
  const searchItems = computed(() => [
    {
@@ -171,6 +198,28 @@
      props: {
        clearable: true,
        placeholder: '请输入站点区域名称/编号/备注'
      }
    },
    {
      label: '开始时间',
      key: 'timeStart',
      type: 'date',
      props: {
        clearable: true,
        type: 'date',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择开始时间'
      }
    },
    {
      label: '结束时间',
      key: 'timeEnd',
      type: 'date',
      props: {
        clearable: true,
        type: 'date',
        valueFormat: 'YYYY-MM-DD',
        placeholder: '请选择结束时间'
      }
    },
    {
@@ -248,12 +297,39 @@
      }
    },
    {
      label: '跨区区域',
      key: 'crossZoneArea',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入跨区区域'
      }
    },
    {
      label: '是否WCS',
      key: 'isWcs',
      type: 'select',
      props: {
        clearable: true,
        options: getBasStationAreaBinaryOptions()
      }
    },
    {
      label: 'WCS数据',
      key: 'wcsData',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入WCS数据'
      }
    },
    {
      label: '容器类型',
      key: 'containerType',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入容器类型'
      }
    },
    {
@@ -272,6 +348,15 @@
      props: {
        clearable: true,
        placeholder: '请输入条码'
      }
    },
    {
      label: '站点别名',
      key: 'stationAlias',
      type: 'input',
      props: {
        clearable: true,
        placeholder: '请输入站点别名'
      }
    },
    {
@@ -454,6 +539,60 @@
  })
  handleDeleteAction = handleDelete
  const buildPreviewMeta = (rows) => {
    const now = new Date()
    return {
      reportDate: now.toLocaleDateString('zh-CN'),
      printedAt: now.toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length,
      reportStyle: { ...BAS_STATION_AREA_REPORT_STYLE }
    }
  }
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchBasStationAreaMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchBasStationAreaPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
      })
    ).records
  }
  const { previewVisible, previewRows, previewMeta, handlePreviewVisibleChange, handleExport, handlePrint } =
    usePrintExportPage({
      downloadFileName: 'bas-station-area.xlsx',
      requestExport: (payload) =>
        fetchExportBasStationAreaReport(payload, {
          headers: {
            Authorization: userStore.accessToken || ''
          }
        }),
      resolvePrintRecords,
      buildPreviewRows: (records) =>
        buildBasStationAreaPrintRows(records, {
          resolveAreaLabel,
          resolveCrossZoneAreaLabel,
          resolveContainerTypeLabel,
          resolveTypeLabel,
          resolveStationAliasLabel,
          resolveUseStatusLabel
        }),
      buildPreviewMeta
    })
  const resolvedPreviewMeta = computed(() =>
    buildBasStationAreaReportMeta({
      previewMeta: previewMeta.value,
      count: previewRows.value.length,
      orientation: previewMeta.value?.reportStyle?.orientation || BAS_STATION_AREA_REPORT_STYLE.orientation
    })
  )
  function handleSearch(params) {
    replaceSearchParams(buildBasStationAreaSearchParams(params))
    getData()
rsf-design/src/views/basic-info/task-path-template/modules/task-path-template-flow-drawer.vue
@@ -2,60 +2,203 @@
  <ElDrawer
    :model-value="visible"
    title="流程图查看"
    size="900px"
    size="92%"
    destroy-on-close
    @update:model-value="handleVisibleChange"
  >
    <ElScrollbar class="h-[calc(100vh-180px)] pr-1">
      <div v-if="loading" class="py-6">
        <ElSkeleton :rows="10" animated />
    <div class="flex h-[calc(100vh-160px)] flex-col gap-4">
      <ElCard shadow="never" class="shrink-0">
        <div class="flex items-start justify-between gap-4">
          <div class="min-w-0">
            <div class="text-base font-semibold text-[var(--art-text-primary)]">
              {{ detail.templateName || detail.templateCode || '--' }}
      </div>
      <div v-else class="space-y-4">
        <ElCard shadow="never" class="art-table-card">
          <template #header>
            <div class="flex items-center justify-between gap-3">
              <div>
                <h3 class="m-0 text-base font-semibold">模板流程快照</h3>
                <p class="m-0 text-sm text-[var(--art-text-secondary)]">
                  这里展示的是后端模板字段组合出的真实流程信息,不做额外假数据推演。
                </p>
            <div class="mt-1 text-sm text-[var(--art-text-secondary)]">
              模板编码 {{ detail.templateCode || '--' }},起点 {{ detail.sourceType || '--' }},终点
              {{ detail.targetType || '--' }}
              </div>
          </div>
          <ElSpace wrap>
              <ElTag :type="detail.statusType || 'info'" effect="light">
                {{ detail.statusText || '--' }}
              </ElTag>
            </div>
          </template>
          <div class="grid gap-3 md:grid-cols-4">
            <div
              v-for="item in flowSnapshot"
              :key="item.key"
              class="rounded-lg border border-[var(--art-border-color)] bg-[var(--art-main-bg-color)] p-4"
            >
              <div class="text-sm text-[var(--art-text-secondary)]">{{ item.title }}</div>
              <div class="mt-2 text-base font-semibold text-[var(--art-text-primary)]">
                {{ item.value }}
              </div>
            </div>
            <ElTag :type="detail.isCurrentType || 'info'" effect="light">
              {{ detail.isCurrentText || '--' }}
            </ElTag>
          </ElSpace>
          </div>
        </ElCard>
        <ElDescriptions title="流程依据" :column="2" border>
          <ElDescriptionsItem label="模板编码">{{ detail.templateCode || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="模板名称">{{ detail.templateName || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="起点类型">{{ detail.sourceType || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="终点类型">{{ detail.targetType || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="步序长度">{{ detail.stepSize ?? '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="优先级">{{ detail.priority ?? '--' }}</ElDescriptionsItem>
        </ElDescriptions>
      <div class="grid min-h-0 flex-1 gap-4 xl:grid-cols-3">
        <ElCard shadow="never" class="min-h-0">
          <template #header>
            <div class="flex items-center justify-between gap-3">
              <span class="font-medium">模板节点</span>
              <span class="text-xs text-[var(--art-text-secondary)]">
                {{ nodeLoading ? '加载中' : `第 ${nodePagination.current} 页 / 共 ${nodePagination.total} 条` }}
              </span>
      </div>
    </ElScrollbar>
          </template>
          <div class="flex h-[calc(100vh-300px)] flex-col">
            <ElSkeleton v-if="loading || nodeLoading" :rows="8" animated />
            <ElEmpty
              v-else-if="nodeRows.length === 0"
              description="暂无节点数据"
              :image-size="100"
            />
            <template v-else>
              <ElTable
                :data="nodeRows"
                border
                highlight-current-row
                height="100%"
                :current-row-key="selectedNodeId"
                row-key="id"
                @current-change="handleNodeClick"
              >
                <ElTableColumn prop="nodeOrder" label="顺序" width="72" align="center" />
                <ElTableColumn prop="nodeCode" label="节点编码" min-width="140" show-overflow-tooltip />
                <ElTableColumn prop="nodeName" label="节点名称" min-width="160" show-overflow-tooltip />
                <ElTableColumn prop="systemCode" label="系统编码" min-width="140" show-overflow-tooltip />
              </ElTable>
              <div class="mt-3 flex justify-end">
                <ElPagination
                  small
                  background
                  layout="prev, pager, next"
                  :current-page="nodePagination.current"
                  :page-size="nodePagination.pageSize"
                  :total="nodePagination.total"
                  @current-change="handleNodePageChange"
                />
              </div>
            </template>
          </div>
        </ElCard>
        <ElCard shadow="never" class="min-h-0">
          <template #header>
            <div class="flex items-center justify-between gap-3">
              <span class="font-medium">子系统流程</span>
              <span class="text-xs text-[var(--art-text-secondary)]">
                {{
                  flowLoading
                    ? '加载中'
                    : selectedNodeId
                      ? `第 ${flowPagination.current} 页 / 共 ${flowPagination.total} 条`
                      : '待选择节点'
                }}
              </span>
            </div>
          </template>
          <div class="flex h-[calc(100vh-300px)] flex-col">
            <div
              v-if="!selectedNodeId"
              class="flex h-full items-center justify-center text-sm text-[var(--art-text-secondary)]"
            >
              请先选择左侧模板节点
            </div>
            <ElSkeleton v-else-if="flowLoading" :rows="8" animated />
            <ElEmpty
              v-else-if="flowRows.length === 0"
              description="暂无流程数据"
              :image-size="100"
            />
            <template v-else>
              <ElTable
                :data="flowRows"
                border
                highlight-current-row
                height="100%"
                :current-row-key="selectedFlowId"
                row-key="id"
                @current-change="handleFlowClick"
              >
                <ElTableColumn prop="flowCode" label="流程编码" min-width="160" show-overflow-tooltip />
                <ElTableColumn prop="flowName" label="流程名称" min-width="180" show-overflow-tooltip />
                <ElTableColumn prop="systemCode" label="系统编码" min-width="140" show-overflow-tooltip />
              </ElTable>
              <div class="mt-3 flex justify-end">
                <ElPagination
                  small
                  background
                  layout="prev, pager, next"
                  :current-page="flowPagination.current"
                  :page-size="flowPagination.pageSize"
                  :total="flowPagination.total"
                  @current-change="handleFlowPageChange"
                />
              </div>
            </template>
          </div>
        </ElCard>
        <ElCard shadow="never" class="min-h-0">
          <template #header>
            <div class="flex items-center justify-between gap-3">
              <span class="font-medium">流程步骤</span>
              <span class="text-xs text-[var(--art-text-secondary)]">
                {{
                  stepLoading
                    ? '加载中'
                    : selectedFlowId
                      ? `第 ${stepPagination.current} 页 / 共 ${stepPagination.total} 条`
                      : '待选择流程'
                }}
              </span>
            </div>
          </template>
          <div class="flex h-[calc(100vh-300px)] flex-col">
            <div
              v-if="!selectedFlowId"
              class="flex h-full items-center justify-center text-sm text-[var(--art-text-secondary)]"
            >
              请先选择中间子系统流程
            </div>
            <ElSkeleton v-else-if="stepLoading" :rows="8" animated />
            <ElEmpty
              v-else-if="stepRows.length === 0"
              description="暂无步骤数据"
              :image-size="100"
            />
            <template v-else>
              <ElTable :data="stepRows" border height="100%" row-key="id">
                <ElTableColumn prop="stepOrder" label="顺序" width="72" align="center" />
                <ElTableColumn prop="stepCode" label="步骤编码" min-width="140" show-overflow-tooltip />
                <ElTableColumn prop="stepName" label="步骤名称" min-width="180" show-overflow-tooltip />
                <ElTableColumn prop="stepType" label="步骤类型" min-width="140" show-overflow-tooltip />
              </ElTable>
              <div class="mt-3 flex justify-end">
                <ElPagination
                  small
                  background
                  layout="prev, pager, next"
                  :current-page="stepPagination.current"
                  :page-size="stepPagination.pageSize"
                  :total="stepPagination.total"
                  @current-change="handleStepPageChange"
                />
              </div>
            </template>
          </div>
        </ElCard>
      </div>
    </div>
  </ElDrawer>
</template>
<script setup>
  import { computed } from 'vue'
  import { buildTaskPathTemplateFlowSnapshot } from '../taskPathTemplatePage.helpers'
  import { computed, ref, watch } from 'vue'
  import { ElMessage } from 'element-plus'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import {
    fetchTaskPathTemplateNodePage
  } from '@/api/task-path-template-node'
  import { fetchSubsystemFlowTemplatePage } from '@/api/subsystem-flow-template'
  import { fetchFlowStepTemplatePage } from '@/api/flow-step-template'
  const props = defineProps({
    visible: { type: Boolean, default: false },
@@ -70,9 +213,194 @@
    set: (value) => emit('update:visible', value)
  })
  const flowSnapshot = computed(() => buildTaskPathTemplateFlowSnapshot(props.detail))
  const nodeLoading = ref(false)
  const flowLoading = ref(false)
  const stepLoading = ref(false)
  const nodeRows = ref([])
  const flowRows = ref([])
  const stepRows = ref([])
  const selectedNodeId = ref(null)
  const selectedFlowId = ref(null)
  const DEFAULT_PAGE_SIZE = 20
  const nodePagination = ref({ current: 1, pageSize: DEFAULT_PAGE_SIZE, total: 0 })
  const flowPagination = ref({ current: 1, pageSize: DEFAULT_PAGE_SIZE, total: 0 })
  const stepPagination = ref({ current: 1, pageSize: DEFAULT_PAGE_SIZE, total: 0 })
  function normalizeRecords(response) {
    return Array.isArray(response?.records) ? response.records : []
  }
  function normalizeTotal(response) {
    const total = Number(response?.total)
    return Number.isNaN(total) ? 0 : total
  }
  function resetFlowState() {
    nodeRows.value = []
    flowRows.value = []
    stepRows.value = []
    selectedNodeId.value = null
    selectedFlowId.value = null
    nodePagination.value.current = 1
    nodePagination.value.total = 0
    flowPagination.value.current = 1
    flowPagination.value.total = 0
    stepPagination.value.current = 1
    stepPagination.value.total = 0
  }
  async function loadStepRows(flowId, current = stepPagination.value.current) {
    if (!flowId) {
      stepRows.value = []
      selectedFlowId.value = null
      stepPagination.value.total = 0
      return
    }
    stepLoading.value = true
    try {
      const response = await guardRequestWithMessage(
        fetchFlowStepTemplatePage({
          current,
          pageSize: stepPagination.value.pageSize,
          flowId
        }),
        { records: [] },
        { timeoutMessage: '流程步骤加载超时,已停止等待' }
      )
      stepRows.value = normalizeRecords(response)
      stepPagination.value.current = current
      stepPagination.value.total = normalizeTotal(response)
    } catch (error) {
      stepRows.value = []
      stepPagination.value.total = 0
      ElMessage.error(error?.message || '流程步骤加载失败')
    } finally {
      stepLoading.value = false
    }
  }
  async function loadFlowRows(node, current = flowPagination.value.current) {
    if (!node?.nodeCode) {
      flowRows.value = []
      stepRows.value = []
      selectedFlowId.value = null
      flowPagination.value.total = 0
      stepPagination.value.total = 0
      return
    }
    flowLoading.value = true
    try {
      const response = await guardRequestWithMessage(
        fetchSubsystemFlowTemplatePage({
          current,
          pageSize: flowPagination.value.pageSize,
          flowCode: node.nodeCode
        }),
        { records: [] },
        { timeoutMessage: '子系统流程加载超时,已停止等待' }
      )
      flowRows.value = normalizeRecords(response)
      flowPagination.value.current = current
      flowPagination.value.total = normalizeTotal(response)
      selectedFlowId.value = null
      stepRows.value = []
      stepPagination.value.current = 1
      stepPagination.value.total = 0
    } catch (error) {
      flowRows.value = []
      stepRows.value = []
      selectedFlowId.value = null
      flowPagination.value.total = 0
      stepPagination.value.total = 0
      ElMessage.error(error?.message || '子系统流程加载失败')
    } finally {
      flowLoading.value = false
    }
  }
  async function loadNodeRows(templateId, current = nodePagination.value.current) {
    if (!templateId) {
      resetFlowState()
      return
    }
    nodeLoading.value = true
    try {
      const response = await guardRequestWithMessage(
        fetchTaskPathTemplateNodePage({
          current,
          pageSize: nodePagination.value.pageSize,
          templateId
        }),
        { records: [] },
        { timeoutMessage: '模板节点加载超时,已停止等待' }
      )
      nodeRows.value = normalizeRecords(response)
      nodePagination.value.current = current
      nodePagination.value.total = normalizeTotal(response)
      selectedNodeId.value = null
      flowRows.value = []
      stepRows.value = []
      flowPagination.value.current = 1
      flowPagination.value.total = 0
      stepPagination.value.current = 1
      stepPagination.value.total = 0
    } catch (error) {
      resetFlowState()
      ElMessage.error(error?.message || '模板节点加载失败')
    } finally {
      nodeLoading.value = false
    }
  }
  function handleNodeClick(node) {
    if (!node || selectedNodeId.value === node.id) return
    selectedNodeId.value = node.id
    selectedFlowId.value = null
    stepRows.value = []
    flowPagination.value.current = 1
    stepPagination.value.current = 1
    loadFlowRows(node)
  }
  function handleFlowClick(flow) {
    if (!flow || selectedFlowId.value === flow.id) return
    selectedFlowId.value = flow.id
    stepPagination.value.current = 1
    loadStepRows(flow.id)
  }
  function handleNodePageChange(current) {
    loadNodeRows(props.detail?.id, current)
  }
  function handleFlowPageChange(current) {
    const node = nodeRows.value.find((item) => item.id === selectedNodeId.value)
    if (!node) return
    loadFlowRows(node, current)
  }
  function handleStepPageChange(current) {
    if (!selectedFlowId.value) return
    loadStepRows(selectedFlowId.value, current)
  }
  function handleVisibleChange(value) {
    visible.value = value
  }
  watch(
    () => [props.visible, props.detail?.id],
    ([isVisible, detailId]) => {
      if (isVisible && detailId) {
        loadNodeRows(detailId)
      }
      if (!isVisible) {
        resetFlowState()
      }
    },
    { immediate: true }
  )
</script>
rsf-design/src/views/basic-info/warehouse-areas/index.vue
@@ -22,6 +22,21 @@
            >
              批量删除
            </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>
@@ -59,10 +74,13 @@
<script setup>
  import { computed, onMounted, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
  import { useAuth } from '@/hooks/core/useAuth'
  import { useTable } from '@/hooks/core/useTable'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { useUserStore } from '@/store/modules/user'
  import { useCrudPage } from '@/views/system/common/useCrudPage'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchDictDataPage } from '@/api/system-manage'
@@ -70,7 +88,9 @@
    fetchCompanysList,
    fetchDeleteWarehouseAreas,
    fetchWarehouseAreasDetail,
    fetchWarehouseAreasMany,
    fetchWarehouseAreasPage,
    fetchExportWarehouseAreasReport,
    fetchSaveWarehouseAreas,
    fetchUpdateWarehouseAreas,
    fetchWarehouseList
@@ -81,10 +101,13 @@
  import {
    buildWarehouseAreasDialogModel,
    buildWarehouseAreasPageQueryParams,
    buildWarehouseAreasPrintRows,
    buildWarehouseAreasSavePayload,
    buildWarehouseAreasSearchParams,
    createWarehouseAreasSearchState,
    getWarehouseAreasPaginationKey,
    WAREHOUSE_AREAS_REPORT_STYLE,
    WAREHOUSE_AREAS_REPORT_TITLE,
    getWarehouseAreasStatusOptions,
    normalizeWarehouseAreasDetailRecord,
    normalizeWarehouseAreasListRow
@@ -93,6 +116,7 @@
  defineOptions({ name: 'WarehouseAreas' })
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
  const searchForm = ref(createWarehouseAreasSearchState())
  const detailDrawerVisible = ref(false)
@@ -164,6 +188,9 @@
      }
    }
  ])
  const reportTitle = WAREHOUSE_AREAS_REPORT_TITLE
  const reportQueryParams = computed(() => buildWarehouseAreasSearchParams(searchForm.value))
  async function openDetail(row) {
    detailDrawerVisible.value = true
@@ -249,6 +276,60 @@
  })
  handleDeleteAction = handleDelete
  const buildPreviewMeta = (rows) => {
    const now = new Date()
    return {
      reportDate: now.toLocaleDateString('zh-CN'),
      printedAt: now.toLocaleString('zh-CN', { hour12: false }),
      operator: userStore.getUserInfo?.name || userStore.getUserInfo?.username || '',
      count: rows.length,
      reportStyle: { ...WAREHOUSE_AREAS_REPORT_STYLE }
    }
  }
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchWarehouseAreasMany(payload.ids)).records
    }
    return defaultResponseAdapter(
      await fetchWarehouseAreasPage({
        ...reportQueryParams.value,
        current: 1,
        pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
      })
    ).records
  }
  const {
    previewVisible,
    previewRows,
    previewMeta,
    handlePreviewVisibleChange,
    handleExport,
    handlePrint
  } = usePrintExportPage({
    downloadFileName: 'warehouse-areas.xlsx',
    requestExport: (payload) =>
      fetchExportWarehouseAreasReport(payload, {
        headers: {
          Authorization: userStore.accessToken || ''
        }
      }),
    resolvePrintRecords,
    buildPreviewRows: (records) => buildWarehouseAreasPrintRows(records),
    buildPreviewMeta
  })
  const resolvedPreviewMeta = computed(() => ({
    ...previewMeta.value,
    reportTitle,
    count: previewRows.value.length || previewMeta.value?.count || 0,
    reportStyle: {
      ...WAREHOUSE_AREAS_REPORT_STYLE,
      ...(previewMeta.value?.reportStyle || {})
    }
  }))
  function handleSearch(params) {
    replaceSearchParams(buildWarehouseAreasSearchParams(params))
    getData()
rsf-design/src/views/manager/task/index.vue
@@ -9,7 +9,30 @@
    />
    <ElCard class="art-table-card">
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="loadPageData" />
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="loadPageData">
        <template #left>
          <ElSpace wrap>
            <ElButton
              v-if="!autoRunEnabled"
              type="primary"
              plain
              :loading="autoRunLoading"
              @click="handleToggleAutoRun(true)"
            >
              自动下发任务
            </ElButton>
            <ElButton
              v-else
              type="warning"
              plain
              :loading="autoRunLoading"
              @click="handleToggleAutoRun(false)"
            >
              暂停自动下发
            </ElButton>
          </ElSpace>
        </template>
      </ArtTableHeader>
      <ArtTable
        :loading="loading"
@@ -21,6 +44,11 @@
      />
    </ElCard>
    <TaskFlowStepDialog
      v-model:visible="flowStepDialogVisible"
      :task-row="activeTaskRow"
    />
    <TaskDetailDrawer
      v-model:visible="detailDrawerVisible"
      :loading="detailLoading"
@@ -31,13 +59,14 @@
      @refresh="loadDetailResources"
      @size-change="handleDetailSizeChange"
      @current-change="handleDetailCurrentChange"
      @flow-step="handleOpenFlowStepFromDetail"
    />
  </div>
</template>
<script setup>
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, reactive, ref } from 'vue'
  import { computed, h, onMounted, onUnmounted, reactive, ref } from 'vue'
  import { useTableColumns } from '@/hooks/core/useTableColumns'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import {
@@ -45,12 +74,15 @@
    fetchCompleteTask,
    fetchPickTask,
    fetchRemoveTask,
    fetchTaskDetail,
    fetchTaskAutoRunFlag,
    fetchTaskItemPage,
    fetchTaskPage,
    fetchTopTask
    fetchTopTask,
    fetchUpdateTaskAutoRunFlag
  } from '@/api/task'
  import TaskDetailDrawer from './modules/task-detail-drawer.vue'
  import TaskExpandPanel from './modules/task-expand-panel.vue'
  import TaskFlowStepDialog from './modules/task-flow-step-dialog.vue'
  import { createTaskTableColumns } from './taskTable.columns'
  import {
    buildTaskPageQueryParams,
@@ -70,6 +102,10 @@
  const detailData = ref({})
  const detailTableData = ref([])
  const activeTaskRow = ref(null)
  const flowStepDialogVisible = ref(false)
  const autoRunEnabled = ref(false)
  const autoRunLoading = ref(false)
  let refreshTimer = null
  const pagination = reactive({
    current: 1,
@@ -192,15 +228,29 @@
  async function openDetailDrawer(row) {
    activeTaskRow.value = row
    detailData.value = normalizeTaskRow(row)
    detailPagination.current = 1
    detailDrawerVisible.value = true
    await loadDetailResources()
  }
  function handleOpenFlowStepFromDetail() {
    if (!activeTaskRow.value) {
      return
    }
    flowStepDialogVisible.value = true
  }
  async function handleActionClick(action, row) {
    try {
      if (action.key === 'view') {
        await openDetailDrawer(row)
        return
      }
      if (action.key === 'flowStep') {
        activeTaskRow.value = row
        flowStepDialogVisible.value = true
        return
      }
@@ -237,7 +287,17 @@
    }
  }
  const { columns, columnChecks } = useTableColumns(() => createTaskTableColumns(handleActionClick))
  const { columns, columnChecks } = useTableColumns(() =>
    createTaskTableColumns({
      handleActionClick,
      createExpandContent: (row) => ({
        name: 'TaskExpandPanelHost',
        render() {
          return h(TaskExpandPanel, { row })
        }
      })
    })
  )
  function updatePaginationState(target, response, fallbackCurrent, fallbackSize) {
    target.total = Number(response?.total || 0)
@@ -273,6 +333,33 @@
    }
  }
  async function loadAutoRunConfig() {
    autoRunLoading.value = true
    try {
      const response = await guardRequestWithMessage(fetchTaskAutoRunFlag(), { val: false }, {
        timeoutMessage: '自动下发配置加载超时,已停止等待'
      })
      const rawValue = response?.val
      autoRunEnabled.value =
        rawValue === true || rawValue === 'true' || rawValue === 1 || rawValue === '1'
    } finally {
      autoRunLoading.value = false
    }
  }
  async function handleToggleAutoRun(enabled) {
    autoRunLoading.value = true
    try {
      await fetchUpdateTaskAutoRunFlag(enabled)
      autoRunEnabled.value = enabled
      ElMessage.success(enabled ? '已开启自动下发任务' : '已暂停自动下发任务')
    } catch (error) {
      ElMessage.error(error?.message || '自动下发配置更新失败')
    } finally {
      autoRunLoading.value = false
    }
  }
  async function loadDetailResources() {
    if (!activeTaskRow.value?.id) {
      return
@@ -280,11 +367,7 @@
    detailLoading.value = true
    try {
      const [detailResponse, taskItemResponse] = await Promise.all([
        guardRequestWithMessage(fetchTaskDetail(activeTaskRow.value.id), {}, {
          timeoutMessage: '任务详情加载超时,已停止等待'
        }),
        guardRequestWithMessage(
      const taskItemResponse = await guardRequestWithMessage(
          fetchTaskItemPage({
            taskId: activeTaskRow.value.id,
            current: detailPagination.current,
@@ -298,13 +381,15 @@
          },
          { timeoutMessage: '任务明细加载超时,已停止等待' }
        )
      ])
      detailData.value = normalizeTaskRow(detailResponse)
      detailData.value = normalizeTaskRow(activeTaskRow.value)
      detailTableData.value = Array.isArray(taskItemResponse?.records)
        ? taskItemResponse.records.map((record) => normalizeTaskItemRow(record))
        : []
      updatePaginationState(detailPagination, taskItemResponse, detailPagination.current, detailPagination.size)
    } catch (error) {
      detailTableData.value = []
      ElMessage.error(error?.message || '任务明细加载失败')
    } finally {
      detailLoading.value = false
    }
@@ -348,5 +433,26 @@
    loadDetailResources()
  }
  onMounted(loadPageData)
  function startAutoRefresh() {
    clearAutoRefresh()
    refreshTimer = window.setInterval(() => {
      void loadPageData()
    }, 5000)
  }
  function clearAutoRefresh() {
    if (refreshTimer) {
      window.clearInterval(refreshTimer)
      refreshTimer = null
    }
  }
  onMounted(async () => {
    await Promise.all([loadPageData(), loadAutoRunConfig()])
    startAutoRefresh()
  })
  onUnmounted(() => {
    clearAutoRefresh()
  })
</script>
rsf-design/src/views/manager/task/modules/task-detail-drawer.vue
@@ -6,27 +6,58 @@
    @update:model-value="handleVisibleChange"
  >
    <div class="flex h-full flex-col gap-4">
      <ElDescriptions :column="4" border>
        <ElDescriptionsItem label="任务号">{{ detail.taskCode || '--' }}</ElDescriptionsItem>
      <div class="grid gap-4 xl:grid-cols-[1.45fr_1fr]">
        <ElCard shadow="never" class="border border-[var(--el-border-color-lighter)]">
          <template #header>
            <div class="flex items-center justify-between">
              <span class="font-medium text-[var(--art-text-gray-900)]">任务基础信息</span>
              <ElTag size="small" effect="plain" type="primary">
                {{ detail.taskCode || '--' }}
              </ElTag>
            </div>
          </template>
          <ElDescriptions :column="2" border>
        <ElDescriptionsItem label="任务状态">{{ detail.taskStatusLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="任务类型">{{ detail.taskTypeLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="设备类型">{{ detail.warehTypeLabel || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="优先级">{{ detail.sort ?? '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="状态">{{ detail.statusText || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="机器人编码">{{ detail.robotCode || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="创建时间">{{ detail.createTimeText || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="更新时间">{{ detail.updateTimeText || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="备注" :span="2">{{ detail.memo || '--' }}</ElDescriptionsItem>
          </ElDescriptions>
        </ElCard>
        <ElCard shadow="never" class="border border-[var(--el-border-color-lighter)]">
          <template #header>
            <div class="flex items-center justify-between">
              <span class="font-medium text-[var(--art-text-gray-900)]">执行路径</span>
              <ElButton text type="primary" @click="$emit('flow-step')">流程步骤</ElButton>
            </div>
          </template>
          <ElDescriptions :column="1" border>
        <ElDescriptionsItem label="源库位">{{ detail.orgLoc || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="源站点">{{ detail.orgSiteLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="目标库位">{{ detail.targLoc || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="目标站点">{{ detail.targSiteLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="托盘码">{{ detail.barcode || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="机器人编码">{{ detail.robotCode || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="优先级">{{ detail.sort ?? '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="状态">{{ detail.statusText || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="更新时间">{{ detail.updateTimeText || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="创建时间">{{ detail.createTimeText || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="备注" :span="2">{{ detail.memo || '--' }}</ElDescriptionsItem>
      </ElDescriptions>
        </ElCard>
      </div>
      <div class="flex items-center justify-between">
        <div class="text-sm text-[var(--art-gray-600)]">任务明细</div>
        <div>
          <div class="text-sm font-medium text-[var(--art-text-gray-900)]">任务明细</div>
          <div class="mt-1 text-xs text-[var(--art-text-gray-500)]">
            查看当前任务关联的业务单据、物料和执行记录
          </div>
        </div>
        <div class="flex items-center gap-2">
        <ElButton :loading="loading" @click="$emit('refresh')">刷新</ElButton>
        </div>
      </div>
      <ArtTable
@@ -51,7 +82,7 @@
    pagination: { type: Object, default: () => ({ current: 1, size: 20, total: 0 }) }
  })
  const emit = defineEmits(['update:visible', 'refresh', 'size-change', 'current-change'])
  const emit = defineEmits(['update:visible', 'refresh', 'size-change', 'current-change', 'flow-step'])
  function handleVisibleChange(visible) {
    emit('update:visible', visible)
rsf-design/src/views/manager/task/modules/task-expand-panel.vue
New file
@@ -0,0 +1,143 @@
<template>
  <div class="rounded-xl bg-[var(--el-fill-color-blank)] px-4 py-4">
    <div class="mb-3 flex items-center justify-between">
      <div class="text-sm font-medium text-[var(--art-gray-900)]">任务明细</div>
      <ElButton text size="small" :loading="loading" @click="loadData">刷新</ElButton>
    </div>
    <ArtTable
      :loading="loading"
      :data="rows"
      :columns="columns"
      empty-text="暂无任务明细"
      :empty-height="'220px'"
    />
  </div>
</template>
<script setup>
  import { onMounted, ref, watch } from 'vue'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchTaskItemPage } from '@/api/task'
  import { normalizeTaskItemRow } from '../taskPage.helpers'
  const props = defineProps({
    row: {
      type: Object,
      default: () => ({})
    }
  })
  const loading = ref(false)
  const rows = ref([])
  const columns = [
    {
      type: 'globalIndex',
      label: '序号',
      width: 72,
      align: 'center'
    },
    {
      prop: 'orderTypeLabel',
      label: '单据类型',
      minWidth: 120,
      showOverflowTooltip: true
    },
    {
      prop: 'wkTypeLabel',
      label: '业务类型',
      minWidth: 120,
      showOverflowTooltip: true
    },
    {
      prop: 'platWorkCode',
      label: '工单号',
      minWidth: 150,
      showOverflowTooltip: true
    },
    {
      prop: 'platItemId',
      label: '行号',
      minWidth: 100,
      showOverflowTooltip: true
    },
    {
      prop: 'matnrCode',
      label: '物料编码',
      minWidth: 150,
      showOverflowTooltip: true
    },
    {
      prop: 'maktx',
      label: '物料名称',
      minWidth: 220,
      showOverflowTooltip: true
    },
    {
      prop: 'batch',
      label: '批次',
      minWidth: 140,
      showOverflowTooltip: true
    },
    {
      prop: 'unit',
      label: '单位',
      width: 100
    },
    {
      prop: 'anfme',
      label: '数量',
      width: 100,
      align: 'right'
    },
    {
      prop: 'updateByText',
      label: '更新人',
      minWidth: 120,
      showOverflowTooltip: true
    },
    {
      prop: 'updateTimeText',
      label: '更新时间',
      minWidth: 180,
      showOverflowTooltip: true
    }
  ]
  async function loadData() {
    if (!props.row?.id) {
      rows.value = []
      return
    }
    loading.value = true
    try {
      const response = await guardRequestWithMessage(
        fetchTaskItemPage({
          taskId: props.row.id,
          current: 1,
          pageSize: 50
        }),
        { records: [] },
        { timeoutMessage: '任务明细加载超时,已停止等待' }
      )
      rows.value = Array.isArray(response?.records)
        ? response.records.map((record) => normalizeTaskItemRow(record))
        : []
    } finally {
      loading.value = false
    }
  }
  watch(
    () => props.row?.id,
    () => {
      void loadData()
    }
  )
  onMounted(() => {
    void loadData()
  })
</script>
rsf-design/src/views/manager/task/modules/task-flow-step-dialog.vue
New file
@@ -0,0 +1,175 @@
<template>
  <ElDialog
    :model-value="visible"
    width="80%"
    top="6vh"
    destroy-on-close
    title="流程步骤"
    @update:model-value="emit('update:visible', $event)"
  >
    <div class="flex flex-col gap-4">
      <div class="rounded-xl border border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-blank)] px-4 py-3">
        <div class="text-sm text-[var(--art-gray-500)]">当前任务</div>
        <div class="mt-1 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-[var(--art-gray-900)]">
          <span>任务号:{{ taskRow?.taskCode || '--' }}</span>
          <span>任务状态:{{ taskRow?.taskStatusLabel || '--' }}</span>
          <span>任务类型:{{ taskRow?.taskTypeLabel || '--' }}</span>
        </div>
      </div>
      <ArtTable
        :loading="loading"
        :data="rows"
        :columns="columns"
        :pagination="pagination"
        @pagination:size-change="handleSizeChange"
        @pagination:current-change="handleCurrentChange"
      />
    </div>
  </ElDialog>
</template>
<script setup>
  import { computed, reactive, ref, watch } from 'vue'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchFlowStepInstancePage } from '@/api/flow-step-instance'
  import { normalizeFlowStepInstanceRow } from '@/views/system/flow-step-instance/flowStepInstancePage.helpers'
  const props = defineProps({
    visible: {
      type: Boolean,
      default: false
    },
    taskRow: {
      type: Object,
      default: () => ({})
    }
  })
  const emit = defineEmits(['update:visible'])
  const loading = ref(false)
  const rows = ref([])
  const pagination = reactive({
    current: 1,
    size: 20,
    total: 0
  })
  const columns = computed(() => [
    {
      type: 'globalIndex',
      label: '序号',
      width: 72,
      align: 'center'
    },
    {
      prop: 'flowInstanceNo',
      label: '流程实例号',
      minWidth: 160,
      showOverflowTooltip: true
    },
    {
      prop: 'stepCode',
      label: '步骤编码',
      minWidth: 140,
      showOverflowTooltip: true
    },
    {
      prop: 'stepName',
      label: '步骤名称',
      minWidth: 180,
      showOverflowTooltip: true
    },
    {
      prop: 'stepType',
      label: '步骤类型',
      minWidth: 140,
      showOverflowTooltip: true
    },
    {
      prop: 'executeResult',
      label: '执行结果',
      minWidth: 140,
      showOverflowTooltip: true
    },
    {
      prop: 'statusText',
      label: '状态',
      width: 120,
      showOverflowTooltip: true
    },
    {
      prop: 'startTimeText',
      label: '开始时间',
      minWidth: 180,
      showOverflowTooltip: true
    },
    {
      prop: 'endTimeText',
      label: '结束时间',
      minWidth: 180,
      showOverflowTooltip: true
    }
  ])
  function updatePaginationState(response) {
    pagination.total = Number(response?.total || 0)
    pagination.current = Number(response?.current || pagination.current || 1)
    pagination.size = Number(response?.size || pagination.size || 20)
  }
  async function loadRows() {
    if (!props.visible || !props.taskRow?.taskCode) {
      return
    }
    loading.value = true
    try {
      const response = await guardRequestWithMessage(
        fetchFlowStepInstancePage({
          taskNo: props.taskRow.taskCode,
          current: pagination.current,
          pageSize: pagination.size
        }),
        {
          records: [],
          total: 0,
          current: pagination.current,
          size: pagination.size
        },
        { timeoutMessage: '流程步骤加载超时,已停止等待' }
      )
      rows.value = Array.isArray(response?.records)
        ? response.records.map((record) => normalizeFlowStepInstanceRow(record))
        : []
      updatePaginationState(response)
    } finally {
      loading.value = false
    }
  }
  function handleSizeChange(size) {
    pagination.size = size
    pagination.current = 1
    void loadRows()
  }
  function handleCurrentChange(current) {
    pagination.current = current
    void loadRows()
  }
  watch(
    () => [props.visible, props.taskRow?.taskCode],
    ([visible]) => {
      if (!visible) {
        rows.value = []
        pagination.current = 1
        return
      }
      pagination.current = 1
      void loadRows()
    }
  )
</script>
rsf-design/src/views/manager/task/taskPage.helpers.js
@@ -55,7 +55,8 @@
    statusText: record['status$'] || '-',
    updateTimeText: record['updateTime$'] || record.updateTime || '-',
    createTimeText: record['createTime$'] || record.createTime || '-',
    canComplete: record.canComplete === true
    canComplete: record.canComplete === true,
    canCancel: record.canCancel === true
  }
}
@@ -84,12 +85,25 @@
  return Number(row.taskStatus) === 199 && Number(row.taskType) === 103
}
export function canTopTask(row = {}) {
  const taskStatus = Number(row.taskStatus)
  const taskType = Number(row.taskType)
  const allowedStatuses = [1, 101]
  const allowedTypes = [1, 101, 10, 103, 11]
  return allowedStatuses.includes(taskStatus) && allowedTypes.includes(taskType)
}
export function getTaskActionList(row = {}) {
  return [
    {
      key: 'view',
      label: '查看详情',
      icon: 'ri:eye-line'
    },
    {
      key: 'flowStep',
      label: '流程步骤',
      icon: 'ri:node-tree'
    },
    ...(row.canComplete
      ? [
@@ -121,12 +135,18 @@
          }
        ]
      : []),
    ...(canTopTask(row)
      ? [
    {
      key: 'top',
      label: '任务置顶',
      icon: 'ri:pushpin-line',
      auth: 'update'
    },
          }
        ]
      : []),
    ...(row.canCancel
      ? [
    {
      key: 'remove',
      label: '取消任务',
@@ -135,6 +155,8 @@
      auth: 'delete'
    }
  ]
      : [])
  ]
}
export async function confirmTaskAction(message) {
rsf-design/src/views/manager/task/taskTable.columns.js
@@ -2,8 +2,17 @@
import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue'
import { getTaskActionList } from './taskPage.helpers'
export function createTaskTableColumns(handleActionClick) {
export function createTaskTableColumns({ handleActionClick, createExpandContent }) {
  return [
    ...(createExpandContent
      ? [
          {
            type: 'expand',
            width: 56,
            formatter: (row) => createExpandContent(row)
          }
        ]
      : []),
    {
      prop: 'taskCode',
      label: '任务号',
rsf-design/src/views/orders/asn-order-item/asnOrderItemPage.helpers.js
@@ -78,6 +78,7 @@
export function createAsnOrderItemSearchState() {
  return {
    condition: '',
    orderId: '',
    orderCode: '',
    poCode: '',
    platWorkCode: '',
@@ -109,6 +110,7 @@
  ;[
    'condition',
    'orderId',
    'orderCode',
    'poCode',
    'platWorkCode',
rsf-design/src/views/orders/asn-order-item/index.vue
@@ -1,5 +1,15 @@
<template>
  <div class="asn-order-item-page art-full-height">
    <ElCard v-if="activeSourceSummary" class="mb-3">
      <div class="flex items-center justify-between gap-3">
        <div class="flex items-center gap-2 text-sm text-[var(--art-text-gray-600)]">
          <span class="font-medium text-[var(--art-text-gray-900)]">当前来源</span>
          <span>入库通知单ID:{{ activeSourceSummary.orderId }}</span>
        </div>
        <ElButton link type="primary" @click="handleClearSourceFilter">查看全部</ElButton>
      </div>
    </ElCard>
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
@@ -49,8 +59,9 @@
</template>
<script setup>
  import { computed, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, ref, watch } from 'vue'
  import { ElButton, ElMessage } from 'element-plus'
  import { useRoute, useRouter } from 'vue-router'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
@@ -81,6 +92,8 @@
  const DEFAULT_PAGE_SIZE = 20
  const userStore = useUserStore()
  const route = useRoute()
  const router = useRouter()
  const reportTitle = ASN_ORDER_ITEM_REPORT_TITLE
  const searchForm = ref(createAsnOrderItemSearchState())
  const selectedRows = ref([])
@@ -88,6 +101,15 @@
  const detailLoading = ref(false)
  const detailData = ref({})
  const activeItemId = ref(null)
  const activeSourceSummary = computed(() => {
    if (searchForm.value.orderId === '' || searchForm.value.orderId === undefined || searchForm.value.orderId === null) {
      return null
    }
    return {
      orderId: searchForm.value.orderId
    }
  })
  const reportQueryParams = computed(() => ({
    ...buildAsnOrderItemSearchParams(searchForm.value),
@@ -274,6 +296,32 @@
    resetSearchParams()
  }
  function applyRouteSearch() {
    const orderId = route.query.orderId
    if (orderId === undefined || orderId === null || orderId === '') {
      return
    }
    searchForm.value.orderId = String(orderId)
  }
  function handleClearSourceFilter() {
    searchForm.value.orderId = ''
    router.replace({
      path: route.path,
      query: {
        ...route.query,
        orderId: undefined
      }
    })
    replaceSearchParams(
      buildAsnOrderItemPageQueryParams({
        ...searchForm.value,
        orderBy: 'id desc'
      })
    )
    getData()
  }
  async function openDetail(row) {
    activeItemId.value = row.id
    detailData.value = normalizeAsnOrderItemDetail(row)
@@ -383,4 +431,32 @@
        ASN_ORDER_ITEM_REPORT_STYLE.orientation
    })
  )
  watch(
    () => route.query.orderId,
    (value) => {
      if (value === undefined || value === null || value === '') {
        return
      }
      applyRouteSearch()
      replaceSearchParams(
        buildAsnOrderItemPageQueryParams({
          ...searchForm.value,
          orderBy: 'id desc'
        })
      )
      getData()
    }
  )
  onMounted(() => {
    applyRouteSearch()
    replaceSearchParams(
      buildAsnOrderItemPageQueryParams({
        ...searchForm.value,
        orderBy: 'id desc'
      })
    )
    getData()
  })
</script>
rsf-design/src/views/orders/asn-order/asnOrderPage.helpers.js
@@ -245,6 +245,11 @@
      icon: 'ri:eye-line'
    },
    {
      key: 'items',
      label: '收货明细',
      icon: 'ri:list-check-3'
    },
    {
      key: 'print',
      label: '打印',
      icon: 'ri:printer-line'
rsf-design/src/views/orders/asn-order/index.vue
@@ -60,6 +60,7 @@
<script setup>
  import { computed, reactive, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import { ElMessage, ElMessageBox } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
@@ -72,7 +73,6 @@
    fetchAsnOrderPage,
    fetchCompleteAsnOrder,
    fetchExportAsnOrderReport,
    fetchGetAsnOrderDetail,
    fetchGetAsnOrderMany
  } from '@/api/asn-order'
  import AsnOrderDetailDrawer from './modules/asn-order-detail-drawer.vue'
@@ -98,6 +98,7 @@
  defineOptions({ name: 'AsnOrder' })
  const userStore = useUserStore()
  const router = useRouter()
  const reportTitle = ASN_ORDER_REPORT_TITLE
  const searchForm = ref(createAsnOrderSearchState())
  const selectedRows = ref([])
@@ -106,6 +107,7 @@
  const detailData = ref({})
  const detailTableData = ref([])
  const activeOrderId = ref(null)
  const activeOrderRow = ref(null)
  const poDialogVisible = ref(false)
  const detailPagination = reactive({
@@ -184,6 +186,8 @@
  async function openDetail(row) {
    activeOrderId.value = row.id
    activeOrderRow.value = row
    detailData.value = normalizeAsnOrderRow(row)
    detailPagination.current = 1
    detailDrawerVisible.value = true
    await loadDetailResources()
@@ -202,6 +206,16 @@
      if (action.key === 'print') {
        await handlePrint({ ids: [row.id], pageSize: 1 })
        return
      }
      if (action.key === 'items') {
        router.push({
          path: '/orders/asn-order-item',
          query: {
            orderId: String(row.id)
          }
        })
        return
      }
@@ -267,15 +281,7 @@
    detailLoading.value = true
    try {
      const [detailResponse, itemResponse] = await Promise.all([
        guardRequestWithMessage(
          fetchGetAsnOrderDetail(activeOrderId.value),
          {},
          {
            timeoutMessage: '入库通知单详情加载超时,已停止等待'
          }
        ),
        guardRequestWithMessage(
      const itemResponse = await guardRequestWithMessage(
          fetchAsnOrderItemPage(
            buildAsnOrderDetailQueryParams({
              orderId: activeOrderId.value,
@@ -293,9 +299,8 @@
            timeoutMessage: '入库通知单明细加载超时,已停止等待'
          }
        )
      ])
      detailData.value = normalizeAsnOrderRow(detailResponse)
      detailData.value = normalizeAsnOrderRow(activeOrderRow.value || {})
      detailTableData.value = Array.isArray(itemResponse?.records)
        ? itemResponse.records.map((item) => normalizeAsnOrderItemRow(item))
        : []
rsf-design/src/views/orders/asn-order/modules/asn-order-detail-drawer.vue
@@ -2,43 +2,35 @@
  <ElDrawer
    :model-value="visible"
    title="入库通知单详情"
    size="88%"
    size="1180px"
    destroy-on-close
    @update:model-value="handleVisibleChange"
  >
    <div class="flex h-full flex-col gap-4">
      <ElDescriptions :column="4" border>
    <ElScrollbar class="h-[calc(100vh-180px)] pr-1">
      <div class="space-y-4">
        <ElDescriptions title="基础信息" :column="4" border>
        <ElDescriptionsItem label="ASN单号">{{ detail.code || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="PO单号">{{ detail.poCode || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="业务类型">{{ detail.wkTypeLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="单据类型">{{
          detail.orderTypeLabel || '--'
        }}</ElDescriptionsItem>
        <ElDescriptionsItem label="单据状态">{{
          detail.exceStatusText || '--'
        }}</ElDescriptionsItem>
        <ElDescriptionsItem label="采购组织">{{
          detail.purchaseOrgName || '--'
        }}</ElDescriptionsItem>
        <ElDescriptionsItem label="采购员">{{
          detail.purchaseUserName || '--'
        }}</ElDescriptionsItem>
          <ElDescriptionsItem label="单据类型">{{ detail.orderTypeLabel || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="单据状态">{{ detail.exceStatusText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="采购组织">{{ detail.purchaseOrgName || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="采购员">{{ detail.purchaseUserName || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="供应商">{{ detail.supplierName || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="应收数量">{{ detail.anfme ?? 0 }}</ElDescriptionsItem>
        <ElDescriptionsItem label="已收数量">{{ detail.qty ?? 0 }}</ElDescriptionsItem>
        <ElDescriptionsItem label="更新时间">{{
          detail.updateTimeText || '--'
        }}</ElDescriptionsItem>
        <ElDescriptionsItem label="创建时间">{{
          detail.createTimeText || '--'
        }}</ElDescriptionsItem>
          <ElDescriptionsItem label="更新时间">{{ detail.updateTimeText || '--' }}</ElDescriptionsItem>
          <ElDescriptionsItem label="创建时间">{{ detail.createTimeText || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="备注" :span="4">{{ detail.memo || '--' }}</ElDescriptionsItem>
      </ElDescriptions>
        <div class="space-y-3">
      <div class="flex items-center justify-between">
        <div class="text-sm text-[var(--art-gray-600)]"
          >明细清单(物料编码/物料名称/供应商批次)</div
        >
            <div class="text-sm font-medium text-[var(--art-gray-900)]">单据明细</div>
            <div class="flex items-center gap-3">
              <ElTag effect="plain">共 {{ data.length }} 条</ElTag>
        <ElButton :loading="loading" @click="$emit('refresh')">刷新</ElButton>
            </div>
      </div>
      <ArtTable
@@ -50,6 +42,8 @@
        @pagination:current-change="$emit('current-change', $event)"
      />
    </div>
      </div>
    </ElScrollbar>
  </ElDrawer>
</template>
rsf-design/src/views/orders/delivery-item/deliveryItemPage.helpers.js
@@ -37,6 +37,7 @@
export function createDeliveryItemSearchState() {
  return {
    condition: '',
    deliveryId: '',
    deliveryCode: '',
    platItemId: '',
    matnrCode: '',
rsf-design/src/views/orders/delivery-item/index.vue
@@ -1,5 +1,15 @@
<template>
  <div class="delivery-item-page art-full-height">
    <ElCard v-if="activeSourceSummary" class="mb-3">
      <div class="flex items-center justify-between gap-3">
        <div class="flex items-center gap-2 text-sm text-[var(--art-text-gray-600)]">
          <span class="font-medium text-[var(--art-text-gray-900)]">当前来源</span>
          <span>DO单ID:{{ activeSourceSummary.deliveryId }}</span>
        </div>
        <ElButton link type="primary" @click="handleClearSourceFilter">查看全部</ElButton>
      </div>
    </ElCard>
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
@@ -30,8 +40,9 @@
</template>
<script setup>
  import { computed, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, ref, watch } from 'vue'
  import { ElButton, ElMessage } from 'element-plus'
  import { useRoute, useRouter } from 'vue-router'
  import { useTable } from '@/hooks/core/useTable'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchDeliveryItemPage, fetchGetDeliveryItemDetail } from '@/api/delivery'
@@ -45,10 +56,21 @@
  defineOptions({ name: 'DeliveryItem' })
  const route = useRoute()
  const router = useRouter()
  const searchForm = ref(createDeliveryItemSearchState())
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const detailData = ref({})
  const activeSourceSummary = computed(() => {
    if (searchForm.value.deliveryId === '' || searchForm.value.deliveryId === undefined || searchForm.value.deliveryId === null) {
      return null
    }
    return {
      deliveryId: searchForm.value.deliveryId
    }
  })
  const searchItems = computed(() => [
    {
@@ -175,4 +197,45 @@
    Object.assign(searchForm.value, createDeliveryItemSearchState())
    resetSearchParams()
  }
  function applyRouteSearch() {
    const deliveryId = route.query.deliveryId
    if (deliveryId === undefined || deliveryId === null || deliveryId === '') {
      return
    }
    searchForm.value.deliveryId = Number.isFinite(Number(deliveryId))
      ? Number(deliveryId)
      : searchForm.value.deliveryId
  }
  function handleClearSourceFilter() {
    searchForm.value.deliveryId = ''
    router.replace({
      path: route.path,
      query: {
        ...route.query,
        deliveryId: undefined
      }
    })
    replaceSearchParams(buildDeliveryItemPageQueryParams(searchForm.value))
    getData()
  }
  watch(
    () => route.query.deliveryId,
    (value) => {
      if (value === undefined || value === null || value === '') {
        return
      }
      applyRouteSearch()
      replaceSearchParams(buildDeliveryItemPageQueryParams(searchForm.value))
      getData()
    }
  )
  onMounted(() => {
    applyRouteSearch()
    replaceSearchParams(buildDeliveryItemPageQueryParams(searchForm.value))
    getData()
  })
</script>
rsf-design/src/views/orders/delivery/deliveryPage.helpers.js
@@ -248,6 +248,11 @@
      key: 'view',
      label: '查看详情',
      icon: 'ri:eye-line'
    },
    {
      key: 'items',
      label: '明细',
      icon: 'ri:list-check-3'
    }
  ]
rsf-design/src/views/orders/delivery/index.vue
@@ -55,6 +55,7 @@
<script setup>
  import { computed, reactive, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import { ElMessage, ElMessageBox } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
@@ -87,6 +88,7 @@
  defineOptions({ name: 'Delivery' })
  const userStore = useUserStore()
  const router = useRouter()
  const reportTitle = 'DO单报表'
  const searchForm = ref(createDeliverySearchState())
  const selectedRows = ref([])
@@ -267,6 +269,18 @@
      openDetail(row)
      return
    }
    if (action.key === 'items') {
      if (!row?.id) {
        return
      }
      router.push({
        path: '/orders/delivery-item',
        query: {
          deliveryId: String(row.id)
        }
      })
      return
    }
    if (action.key === 'delete') {
      handleDelete(row)
    }
rsf-design/src/views/orders/out-stock-item/index.vue
@@ -1,5 +1,15 @@
<template>
  <div class="out-stock-item-page art-full-height">
    <ElCard v-if="activeSourceSummary" class="mb-3">
      <div class="flex items-center justify-between gap-3">
        <div class="flex items-center gap-2 text-sm text-[var(--art-text-gray-600)]">
          <span class="font-medium text-[var(--art-text-gray-900)]">当前来源</span>
          <span>出库单ID:{{ activeSourceSummary.orderId }}</span>
        </div>
        <ElButton link type="primary" @click="handleClearSourceFilter">查看全部</ElButton>
      </div>
    </ElCard>
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
@@ -30,8 +40,9 @@
</template>
<script setup>
  import { computed, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, ref, watch } from 'vue'
  import { ElButton, ElMessage } from 'element-plus'
  import { useRoute, useRouter } from 'vue-router'
  import { useTable } from '@/hooks/core/useTable'
  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
  import { fetchGetOutStockItemDetail, fetchOutStockItemPage } from '@/api/out-stock-item'
@@ -45,10 +56,24 @@
  defineOptions({ name: 'OutStockItem' })
  const route = useRoute()
  const router = useRouter()
  const searchForm = ref(createOutStockItemSearchState())
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const detailData = ref({})
  const activeSourceSummary = computed(() => {
    if (
      searchForm.value.orderId === '' ||
      searchForm.value.orderId === undefined ||
      searchForm.value.orderId === null
    ) {
      return null
    }
    return {
      orderId: searchForm.value.orderId
    }
  })
  const searchItems = computed(() => [
    {
@@ -214,4 +239,45 @@
    Object.assign(searchForm.value, createOutStockItemSearchState())
    resetSearchParams()
  }
  function applyRouteSearch() {
    const orderId = route.query.orderId
    if (orderId === undefined || orderId === null || orderId === '') {
      return
    }
    searchForm.value.orderId = Number.isFinite(Number(orderId))
      ? Number(orderId)
      : searchForm.value.orderId
  }
  function handleClearSourceFilter() {
    searchForm.value.orderId = ''
    router.replace({
      path: route.path,
      query: {
        ...route.query,
        orderId: undefined
      }
    })
    replaceSearchParams(buildOutStockItemPageQueryParams(searchForm.value))
    getData()
  }
  watch(
    () => route.query.orderId,
    (value) => {
      if (value === undefined || value === null || value === '') {
        return
      }
      applyRouteSearch()
      replaceSearchParams(buildOutStockItemPageQueryParams(searchForm.value))
      getData()
    }
  )
  onMounted(() => {
    applyRouteSearch()
    replaceSearchParams(buildOutStockItemPageQueryParams(searchForm.value))
    getData()
  })
</script>
rsf-design/src/views/orders/out-stock-item/outStockItemPage.helpers.js
@@ -43,6 +43,7 @@
export function createOutStockItemSearchState() {
  return {
    condition: '',
    orderId: '',
    orderCode: '',
    poCode: '',
    platItemId: '',
@@ -75,6 +76,7 @@
  ].forEach((key) => pushText(result, key, params[key]))
  pushNumber(result, 'status', params.status)
  pushNumber(result, 'orderId', params.orderId)
  return result
}
rsf-design/src/views/orders/out-stock/index.vue
@@ -39,19 +39,32 @@
      />
    </ElCard>
    <OutStockDetailDrawer v-model:visible="detailDrawerVisible" :loading="detailLoading" :detail="detailData" />
    <OutStockDetailDrawer
      v-model:visible="detailDrawerVisible"
      :loading="detailLoading"
      :items-loading="detailItemsLoading"
      :detail="detailData"
      :item-rows="detailItemRows"
      :item-columns="detailItemColumns"
      :pagination="detailItemPagination"
      @refresh="loadDetailResources"
      @size-change="handleDetailSizeChange"
      @current-change="handleDetailCurrentChange"
    />
  </div>
</template>
<script setup>
  import { computed, ref } from 'vue'
  import { computed, reactive, ref } from 'vue'
  import { ElMessage, ElMessageBox } from 'element-plus'
  import { useRouter } from 'vue-router'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  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 { fetchOutStockItemPage } from '@/api/out-stock-item'
  import {
    fetchCancelOutStock,
    fetchCompleteOutStock,
@@ -73,17 +86,30 @@
    normalizeOutStockRow
  } from './outStockPage.helpers'
  import { createOutStockTableColumns } from './outStockTable.columns'
  import { createOutStockItemTableColumns } from '../out-stock-item/outStockItemTable.columns.js'
  import { normalizeOutStockItemRow } from '../out-stock-item/outStockItemPage.helpers.js'
  defineOptions({ name: 'OutStock' })
  const userStore = useUserStore()
  const router = useRouter()
  const reportTitle = OUT_STOCK_REPORT_TITLE
  const searchForm = ref(createOutStockSearchState())
  const selectedRows = ref([])
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const detailItemsLoading = ref(false)
  const detailData = ref({})
  const detailItemRows = ref([])
  const activeOutStockId = ref(null)
  const detailItemPagination = reactive({
    current: 1,
    size: 20,
    total: 0
  })
  const detailItemColumns = computed(() =>
    createOutStockItemTableColumns().filter((column) => column.prop !== 'operation')
  )
  const reportQueryParams = computed(() => buildOutStockSearchParams(searchForm.value))
  const searchItems = computed(() => [
@@ -138,22 +164,69 @@
    { label: '备注', key: 'memo', type: 'input', props: { clearable: true, placeholder: '请输入备注' } }
  ])
  function openDetail(row) {
  async function loadDetailResources() {
    if (!activeOutStockId.value) {
      return
    }
    detailLoading.value = true
    detailItemsLoading.value = true
    try {
      const [detailResponse, itemResponse] = await Promise.all([
        guardRequestWithMessage(fetchGetOutStockDetail(activeOutStockId.value), {}, { timeoutMessage: '出库单详情加载超时,已停止等待' }),
        guardRequestWithMessage(
          fetchOutStockItemPage({
            orderId: activeOutStockId.value,
            current: detailItemPagination.current,
            pageSize: detailItemPagination.size
          }),
          {
            records: [],
            total: 0,
            current: detailItemPagination.current,
            size: detailItemPagination.size
          },
          { timeoutMessage: '出库单明细加载超时,已停止等待' }
        )
      ])
      detailData.value = normalizeOutStockRow(detailResponse)
      const normalized = defaultResponseAdapter(itemResponse)
      detailItemRows.value = normalized.records.map((item) => normalizeOutStockItemRow(item))
      detailItemPagination.total = Number(normalized.total || 0)
      detailItemPagination.current = Number(normalized.current || detailItemPagination.current || 1)
      detailItemPagination.size = Number(normalized.size || detailItemPagination.size || 20)
    } finally {
      detailLoading.value = false
      detailItemsLoading.value = false
    }
  }
  async function openDetail(row) {
    activeOutStockId.value = row.id
    detailDrawerVisible.value = true
    detailLoading.value = true
    guardRequestWithMessage(fetchGetOutStockDetail(row.id), {}, { timeoutMessage: '出库单详情加载超时,已停止等待' })
      .then((detail) => {
        detailData.value = normalizeOutStockRow(detail)
      })
      .catch((error) => {
    detailItemPagination.current = 1
    detailData.value = {}
    detailItemRows.value = []
    try {
      await loadDetailResources()
    } catch (error) {
        detailDrawerVisible.value = false
        detailData.value = {}
      detailItemRows.value = []
        ElMessage.error(error?.message || '获取出库单详情失败')
      })
      .finally(() => {
        detailLoading.value = false
      })
    }
  }
  function handleDetailSizeChange(size) {
    detailItemPagination.size = size
    detailItemPagination.current = 1
    loadDetailResources()
  }
  function handleDetailCurrentChange(current) {
    detailItemPagination.current = current
    loadDetailResources()
  }
  async function handleComplete(row) {
@@ -167,7 +240,7 @@
      ElMessage.success('完成成功')
      await refreshData()
      if (detailDrawerVisible.value && activeOutStockId.value === row.id) {
        openDetail(row)
        await loadDetailResources()
      }
    } catch (error) {
      if (error === 'cancel' || error?.message === 'cancel') return
@@ -186,7 +259,7 @@
      ElMessage.success('取消成功')
      await refreshData()
      if (detailDrawerVisible.value && activeOutStockId.value === row.id) {
        openDetail(row)
        await loadDetailResources()
      }
    } catch (error) {
      if (error === 'cancel' || error?.message === 'cancel') return
@@ -216,6 +289,15 @@
      openDetail(row)
      return
    }
    if (action.key === 'items') {
      router.push({
        path: '/orders/out-stock-item',
        query: {
          orderId: String(row.id)
        }
      })
      return
    }
    if (action.key === 'print') {
      await handlePrint({ ids: [row.id], pageSize: 1 })
      return
rsf-design/src/views/orders/out-stock/modules/out-stock-detail-drawer.vue
@@ -2,14 +2,14 @@
  <ElDrawer
    v-model="visibleProxy"
    :title="'出库单详情'"
    size="720px"
    size="1180px"
    destroy-on-close
    append-to-body
  >
    <ElScrollbar class="h-full">
    <ElScrollbar class="h-[calc(100vh-180px)] pr-1">
      <ElSkeleton :loading="loading" animated :rows="8">
        <div class="space-y-4">
          <ElDescriptions :column="2" border>
          <ElDescriptions title="基础信息" :column="2" border>
            <ElDescriptionsItem label="出库单号">{{ detail.code || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="PO单号">{{ detail.poCode || '--' }}</ElDescriptionsItem>
            <ElDescriptionsItem label="单据类型">{{ detail.typeLabel || '--' }}</ElDescriptionsItem>
@@ -33,12 +33,31 @@
            <ElDescriptionsItem label="备注" :span="2">{{ detail.memo || '--' }}</ElDescriptionsItem>
          </ElDescriptions>
          <ElDescriptions :column="2" border>
          <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 class="space-y-3">
            <div class="flex items-center justify-between">
              <div class="text-sm font-medium text-[var(--art-gray-900)]">单据明细</div>
              <div class="flex items-center gap-3">
                <ElTag effect="plain">共 {{ itemRows.length }} 条</ElTag>
                <ElButton :loading="itemsLoading" @click="$emit('refresh')">刷新</ElButton>
              </div>
            </div>
            <ArtTable
              :loading="itemsLoading"
              :data="itemRows"
              :columns="itemColumns"
              :pagination="pagination"
              @pagination:size-change="$emit('size-change', $event)"
              @pagination:current-change="$emit('current-change', $event)"
            />
          </div>
        </div>
      </ElSkeleton>
    </ElScrollbar>
@@ -47,6 +66,7 @@
<script setup>
  import { computed } from 'vue'
  import ArtTable from '@/components/core/tables/art-table/index.vue'
  defineOptions({ name: 'OutStockDetailDrawer' })
@@ -59,13 +79,29 @@
      type: Boolean,
      default: false
    },
    itemsLoading: {
      type: Boolean,
      default: false
    },
    detail: {
      type: Object,
      default: () => ({})
    },
    itemRows: {
      type: Array,
      default: () => []
    },
    itemColumns: {
      type: Array,
      default: () => []
    },
    pagination: {
      type: Object,
      default: () => ({ current: 1, size: 20, total: 0 })
    }
  })
  const emit = defineEmits(['update:visible'])
  const emit = defineEmits(['update:visible', 'refresh', 'size-change', 'current-change'])
  const visibleProxy = computed({
    get: () => props.visible,
rsf-design/src/views/orders/out-stock/outStockPage.helpers.js
@@ -157,6 +157,11 @@
      icon: 'ri:eye-line'
    },
    {
      key: 'items',
      label: '明细',
      icon: 'ri:list-check-3'
    },
    {
      key: 'print',
      label: '打印',
      icon: 'ri:printer-line'
rsf-design/src/views/orders/preparation-item/index.vue
@@ -1,5 +1,15 @@
<template>
  <div class="preparation-item-page art-full-height">
    <ElCard v-if="activeSourceSummary" class="mb-3">
      <div class="flex items-center justify-between gap-3">
        <div class="flex items-center gap-2 text-sm text-[var(--art-text-gray-600)]">
          <span class="font-medium text-[var(--art-text-gray-900)]">当前来源</span>
          <span>备料单ID:{{ activeSourceSummary.orderId }}</span>
        </div>
        <ElButton link type="primary" @click="handleClearSourceFilter">查看全部</ElButton>
      </div>
    </ElCard>
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
@@ -49,9 +59,9 @@
</template>
<script setup>
  import { computed, ref } from 'vue'
  import { useRoute } from 'vue-router'
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, ref, watch } from 'vue'
  import { useRoute, useRouter } from 'vue-router'
  import { ElButton, ElMessage } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
@@ -82,6 +92,7 @@
  defineOptions({ name: 'PreparationItem' })
  const route = useRoute()
  const router = useRouter()
  const userStore = useUserStore()
  const initialOrderId = route.query.orderId || route.query.id
  const searchForm = ref(
@@ -96,6 +107,18 @@
  const reportTitle = PREPARATION_ITEM_REPORT_TITLE
  const reportColumns = getPreparationItemReportColumns()
  const reportQueryParams = computed(() => buildPreparationItemSearchParams(searchForm.value))
  const activeSourceSummary = computed(() => {
    if (
      searchForm.value.orderId === '' ||
      searchForm.value.orderId === undefined ||
      searchForm.value.orderId === null
    ) {
      return null
    }
    return {
      orderId: searchForm.value.orderId
    }
  })
  const searchItems = computed(() => [
    {
@@ -247,6 +270,45 @@
    resetSearchParams(buildPreparationItemPageQueryParams(createPreparationItemSearchState(resetSeed)))
  }
  function applyRouteSearch() {
    const orderId = route.query.orderId || route.query.id
    if (orderId === undefined || orderId === null || orderId === '') {
      return
    }
    searchForm.value.orderId = Number.isFinite(Number(orderId))
      ? Number(orderId)
      : searchForm.value.orderId
  }
  function handleClearSourceFilter() {
    searchForm.value.orderId = ''
    router.replace({
      path: route.path,
      query: {
        ...route.query,
        orderId: undefined,
        id: undefined
      }
    })
    replaceSearchParams(buildPreparationItemPageQueryParams(searchForm.value))
    getData()
  }
  watch(
    () => [route.query.orderId, route.query.id],
    ([orderId, id]) => {
      if (
        (orderId === undefined || orderId === null || orderId === '') &&
        (id === undefined || id === null || id === '')
      ) {
        return
      }
      applyRouteSearch()
      replaceSearchParams(buildPreparationItemPageQueryParams(searchForm.value))
      getData()
    }
  )
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchGetPreparationItemMany(payload.ids)).records
@@ -297,4 +359,10 @@
        previewMeta.value?.reportStyle?.orientation || PREPARATION_ITEM_REPORT_STYLE.orientation
    })
  )
  onMounted(() => {
    applyRouteSearch()
    replaceSearchParams(buildPreparationItemPageQueryParams(searchForm.value))
    getData()
  })
</script>
rsf-design/src/views/orders/preparation/index.vue
@@ -58,6 +58,7 @@
<script setup>
  import { computed, reactive, ref } from 'vue'
  import { ElMessage, ElMessageBox } from 'element-plus'
  import { useRouter } from 'vue-router'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
@@ -93,6 +94,7 @@
  defineOptions({ name: 'Preparation' })
  const userStore = useUserStore()
  const router = useRouter()
  const reportTitle = PREPARATION_REPORT_TITLE
  const searchForm = ref(createPreparationSearchState())
  const selectedRows = ref([])
@@ -230,6 +232,16 @@
        return
      }
      if (action.key === 'items') {
        router.push({
          path: '/orders/preparation-item',
          query: {
            orderId: String(row.id)
          }
        })
        return
      }
      if (action.key === 'complete') {
        await ElMessageBox.confirm(`确定完成备料单 ${row.code || ''} 吗?`, '完成确认', {
          confirmButtonText: '确定',
rsf-design/src/views/orders/preparation/modules/preparation-detail-drawer.vue
@@ -2,20 +2,26 @@
  <ElDrawer
    :model-value="visible"
    title="备料单详情"
    size="88%"
    size="1180px"
    destroy-on-close
    @update:model-value="handleVisibleChange"
  >
    <div class="flex h-full flex-col gap-4">
      <ElDescriptions :column="4" border>
    <ElScrollbar class="h-[calc(100vh-180px)] pr-1">
      <div class="space-y-4">
        <ElDescriptions title="基础信息" :column="4" border>
        <ElDescriptionsItem label="备料单号">{{ detail.code || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="PO单号">{{ detail.poCode || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="业务类型">{{ detail.wkTypeLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="单据类型">{{ detail.typeLabel || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="单据状态">
          <ElTag :type="detail.exceStatusTagType || 'info'">{{ detail.exceStatusText || '--' }}</ElTag>
            <ElTag :type="detail.exceStatusTagType || 'info'" effect="light">
              {{ detail.exceStatusText || '--' }}
            </ElTag>
        </ElDescriptionsItem>
        <ElDescriptionsItem label="释放状态">
          <ElTag :type="detail.rleStatusTagType || 'info'">{{ detail.rleStatusText || '--' }}</ElTag>
            <ElTag :type="detail.rleStatusTagType || 'info'" effect="light">
              {{ detail.rleStatusText || '--' }}
            </ElTag>
        </ElDescriptionsItem>
        <ElDescriptionsItem label="物流单号">{{ detail.logisNo || '--' }}</ElDescriptionsItem>
        <ElDescriptionsItem label="业务时间">{{ detail.businessTimeText || '--' }}</ElDescriptionsItem>
@@ -30,9 +36,13 @@
        <ElDescriptionsItem label="备注" :span="4">{{ detail.memo || '--' }}</ElDescriptionsItem>
      </ElDescriptions>
        <div class="space-y-3">
      <div class="flex items-center justify-between">
        <div class="text-sm text-[var(--art-gray-600)]">明细清单(物料编码/物料名称/供应商批次)</div>
            <div class="text-sm font-medium text-[var(--art-gray-900)]">单据明细</div>
            <div class="flex items-center gap-3">
              <ElTag effect="plain">共 {{ data.length }} 条</ElTag>
        <ElButton :loading="loading" @click="$emit('refresh')">刷新</ElButton>
            </div>
      </div>
      <ArtTable
@@ -44,6 +54,8 @@
        @pagination:current-change="$emit('current-change', $event)"
      />
    </div>
      </div>
    </ElScrollbar>
  </ElDrawer>
</template>
rsf-design/src/views/orders/preparation/preparationPage.helpers.js
@@ -186,6 +186,7 @@
  const normalizedRow = normalizePreparationRow(row)
  return [
    { key: 'view', label: '查看详情', icon: 'ri:eye-line' },
    { key: 'items', label: '明细', icon: 'ri:list-check-3' },
    { key: 'print', label: '打印', icon: 'ri:printer-line' },
    {
      key: 'complete',
rsf-design/src/views/orders/purchase-item/index.vue
@@ -1,5 +1,15 @@
<template>
  <div class="purchase-item-page art-full-height">
    <ElCard v-if="activeSourceSummary" class="mb-3">
      <div class="flex items-center justify-between gap-3">
        <div class="flex items-center gap-2 text-sm text-[var(--art-text-gray-600)]">
          <span class="font-medium text-[var(--art-text-gray-900)]">当前来源</span>
          <span>PO单ID:{{ activeSourceSummary.purchaseId }}</span>
        </div>
        <ElButton link type="primary" @click="handleClearSourceFilter">查看全部</ElButton>
      </div>
    </ElCard>
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
@@ -48,8 +58,9 @@
</template>
<script setup>
  import { computed, onMounted, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, ref, watch } from 'vue'
  import { ElButton, ElMessage } from 'element-plus'
  import { useRoute, useRouter } from 'vue-router'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
@@ -79,12 +90,23 @@
  defineOptions({ name: 'PurchaseItem' })
  const userStore = useUserStore()
  const route = useRoute()
  const router = useRouter()
  const reportTitle = PURCHASE_ITEM_REPORT_TITLE
  const searchForm = ref(createPurchaseItemSearchState())
  const selectedRows = ref([])
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const detailData = ref({})
  const activeSourceSummary = computed(() => {
    if (searchForm.value.purchaseId === '' || searchForm.value.purchaseId === undefined || searchForm.value.purchaseId === null) {
      return null
    }
    return {
      purchaseId: searchForm.value.purchaseId
    }
  })
  const searchItems = computed(() => [
    {
@@ -306,6 +328,29 @@
    resetSearchParams()
  }
  function applyRouteSearch() {
    const purchaseId = route.query.purchaseId
    if (purchaseId === undefined || purchaseId === null || purchaseId === '') {
      return
    }
    searchForm.value.purchaseId = Number.isFinite(Number(purchaseId))
      ? Number(purchaseId)
      : searchForm.value.purchaseId
  }
  function handleClearSourceFilter() {
    searchForm.value.purchaseId = ''
    router.replace({
      path: route.path,
      query: {
        ...route.query,
        purchaseId: undefined
      }
    })
    replaceSearchParams(buildPurchaseItemPageQueryParams(searchForm.value))
    getData()
  }
  const buildPreviewMeta = (rows) => {
    const now = new Date()
    return {
@@ -364,7 +409,21 @@
    })
  )
  watch(
    () => route.query.purchaseId,
    (value) => {
      if (value === undefined || value === null || value === '') {
        return
      }
      applyRouteSearch()
      replaceSearchParams(buildPurchaseItemPageQueryParams(searchForm.value))
      getData()
    }
  )
  onMounted(() => {
    applyRouteSearch()
    replaceSearchParams(buildPurchaseItemPageQueryParams(searchForm.value))
    getData()
  })
</script>
rsf-design/src/views/orders/purchase/index.vue
@@ -74,6 +74,7 @@
<script setup>
  import { computed, onMounted, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import { ElMessage } from 'element-plus'
  import { useUserStore } from '@/store/modules/user'
  import { useAuth } from '@/hooks/core/useAuth'
@@ -119,6 +120,7 @@
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
  const router = useRouter()
  const reportTitle = PURCHASE_REPORT_TITLE
  const searchForm = ref(createPurchaseSearchState())
@@ -276,6 +278,18 @@
    }
  }
  function openPurchaseItems(row) {
    if (!row?.id) {
      return
    }
    router.push({
      path: '/orders/purchase-item',
      query: {
        purchaseId: String(row.id)
      }
    })
  }
  const {
    columns,
    columnChecks,
@@ -299,6 +313,7 @@
      columnsFactory: () =>
        createPurchaseTableColumns({
          handleView: openDetail,
          handleViewItems: openPurchaseItems,
          handleEdit: hasAuth('update') ? openEditDialog : null,
          handleDelete: hasAuth('delete') ? (row) => handleDeleteAction?.(row) : null,
          canEdit: hasAuth('update'),
rsf-design/src/views/orders/purchase/purchaseTable.columns.js
@@ -5,6 +5,7 @@
export function createPurchaseTableColumns({
  handleView,
  handleViewItems,
  handleEdit,
  handleDelete,
  canEdit = true,
@@ -152,6 +153,14 @@
      formatter: (row) => {
        const operations = [{ key: 'view', label: '详情', icon: 'ri:eye-line' }]
        if (handleViewItems) {
          operations.push({
            key: 'items',
            label: '明细',
            icon: 'ri:list-check-3'
          })
        }
        if (canEdit && handleEdit) {
          operations.push({ key: 'edit', label: '编辑', icon: 'ri:pencil-line' })
        }
@@ -169,6 +178,7 @@
          list: operations,
          onClick: (item) => {
            if (item.key === 'view') handleView?.(row)
            if (item.key === 'items') handleViewItems?.(row)
            if (item.key === 'edit') handleEdit?.(row)
            if (item.key === 'delete') handleDelete?.(row)
          }
rsf-design/src/views/orders/transfer-item/index.vue
@@ -1,5 +1,15 @@
<template>
  <div class="transfer-item-page art-full-height">
    <ElCard v-if="activeSourceSummary" class="mb-3">
      <div class="flex items-center justify-between gap-3">
        <div class="flex items-center gap-2 text-sm text-[var(--art-text-gray-600)]">
          <span class="font-medium text-[var(--art-text-gray-900)]">当前来源</span>
          <span>调拨单ID:{{ activeSourceSummary.transferId }}</span>
        </div>
        <ElButton link type="primary" @click="handleClearSourceFilter">查看全部</ElButton>
      </div>
    </ElCard>
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
@@ -48,8 +58,9 @@
</template>
<script setup>
  import { computed, ref } from 'vue'
  import { ElMessage } from 'element-plus'
  import { computed, onMounted, ref, watch } from 'vue'
  import { ElButton, ElMessage } from 'element-plus'
  import { useRoute, useRouter } from 'vue-router'
  import { useUserStore } from '@/store/modules/user'
  import { useTable } from '@/hooks/core/useTable'
  import { defaultResponseAdapter } from '@/utils/table/tableUtils'
@@ -80,12 +91,23 @@
  defineOptions({ name: 'TransferItem' })
  const userStore = useUserStore()
  const route = useRoute()
  const router = useRouter()
  const reportTitle = TRANSFER_ITEM_REPORT_TITLE
  const searchForm = ref(createTransferItemSearchState())
  const selectedRows = ref([])
  const detailDrawerVisible = ref(false)
  const detailLoading = ref(false)
  const detailData = ref({})
  const activeSourceSummary = computed(() => {
    if (searchForm.value.transferId === '' || searchForm.value.transferId === undefined || searchForm.value.transferId === null) {
      return null
    }
    return {
      transferId: searchForm.value.transferId
    }
  })
  const searchItems = computed(() => [
    {
@@ -372,6 +394,29 @@
    resetSearchParams()
  }
  function applyRouteSearch() {
    const transferId = route.query.transferId
    if (transferId === undefined || transferId === null || transferId === '') {
      return
    }
    searchForm.value.transferId = Number.isFinite(Number(transferId))
      ? Number(transferId)
      : searchForm.value.transferId
  }
  function handleClearSourceFilter() {
    searchForm.value.transferId = ''
    router.replace({
      path: route.path,
      query: {
        ...route.query,
        transferId: undefined
      }
    })
    replaceSearchParams(buildTransferItemPageQueryParams(searchForm.value))
    getData()
  }
  const resolvePrintRecords = async (payload) => {
    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
      return defaultResponseAdapter(await fetchTransferItemMany(payload.ids)).records
@@ -425,5 +470,22 @@
      orientation: previewMeta.value?.reportStyle?.orientation || TRANSFER_ITEM_REPORT_STYLE.orientation
    })
  )
</script>
  watch(
    () => route.query.transferId,
    (value) => {
      if (value === undefined || value === null || value === '') {
        return
      }
      applyRouteSearch()
      replaceSearchParams(buildTransferItemPageQueryParams(searchForm.value))
      getData()
    }
  )
  onMounted(() => {
    applyRouteSearch()
    replaceSearchParams(buildTransferItemPageQueryParams(searchForm.value))
    getData()
  })
</script>
rsf-design/src/views/orders/transfer/index.vue
@@ -77,6 +77,7 @@
<script setup>
  import { computed, onMounted, reactive, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import { ElMessage, ElMessageBox } from 'element-plus'
  import { useAuth } from '@/hooks/core/useAuth'
  import { useUserStore } from '@/store/modules/user'
@@ -129,6 +130,7 @@
  const { hasAuth } = useAuth()
  const userStore = useUserStore()
  const router = useRouter()
  const reportTitle = TRANSFER_REPORT_TITLE
  const searchForm = ref(createTransferSearchState())
@@ -296,6 +298,16 @@
      await openDetail(row)
      return
    }
    if (action?.key === 'items') {
      if (!row?.id) return
      router.push({
        path: '/orders/transfer-item',
        query: {
          transferId: String(row.id)
        }
      })
      return
    }
    if (action?.key === 'edit') {
      if (!canUpdate.value) return
      await openEditDialog(row)
rsf-design/src/views/orders/transfer/transferPage.helpers.js
@@ -306,6 +306,7 @@
  const normalizedRow = normalizeTransferRow(row)
  const actions = [
    { key: 'view', label: '查看详情', icon: 'ri:eye-line' },
    { key: 'items', label: '明细', icon: 'ri:list-check-3' },
    { key: 'edit', label: '编辑', icon: 'ri:pencil-line' }
  ]
  if (Number(normalizedRow.exceStatus) === 0) {
rsf-design/src/views/system/menu/menuTable.columns.js
@@ -62,6 +62,16 @@
      }
    },
    {
      prop: 'component',
      label: '组件标识',
      minWidth: 160,
      showOverflowTooltip: true,
      formatter: (row) => {
        if (row.meta?.isAuthButton) return ''
        return row.component || ''
      }
    },
    {
      prop: 'authority',
      label: '权限标识',
      minWidth: 180,
@@ -77,6 +87,12 @@
      prop: 'sort',
      label: '排序',
      width: 90
    },
    {
      prop: 'id',
      label: 'ID',
      width: 96,
      align: 'center'
    },
    {
      prop: 'status',
@@ -98,9 +114,9 @@
      prop: 'operation',
      label: '操作',
      width: 180,
      align: 'right',
      align: 'center',
      formatter: (row) => {
        const buttonStyle = { class: 'flex justify-end' }
        const buttonStyle = { class: 'flex justify-center' }
        if (row.meta?.isAuthButton) {
          return h('div', buttonStyle, [
            h(ArtButtonTable, {
rsf-framework/src/main/java/com/vincent/rsf/framework/generators/RsfDesignGenerator.java
@@ -186,7 +186,7 @@
        writeTemplate("TableColumns", pageDirectory, simpleEntityName + "Table.columns.js");
        writeTemplate("Search", modulesDirectory, kebabEntityName + "-search.vue");
        writeTemplate("EditDialog", modulesDirectory, kebabEntityName + "-edit-dialog.vue");
        writeTemplate("Api", resolveFrontendApiDirectory(), normalizedFrontendApiModule + ".js");
        writeTemplate("Api", resolveFrontendApiDirectory(), resolveFrontendApiFileName());
    }
    private String resolveControllerDirectory() {
@@ -207,6 +207,14 @@
            return directory;
        }
        return directory + normalizedFrontendApiModule.substring(0, index + 1);
    }
    private String resolveFrontendApiFileName() {
        int index = normalizedFrontendApiModule.lastIndexOf('/');
        if (index < 0) {
            return normalizedFrontendApiModule + ".js";
        }
        return normalizedFrontendApiModule.substring(index + 1) + ".js";
    }
    private void writeTemplate(String templateName, String directory, String fileName) throws IOException {
@@ -677,14 +685,6 @@
                        .append("StatusMeta(row.statusBool ?? row.status)),\n");
                continue;
            }
            if (isNumericColumn(column)) {
                sb.append("    createNumberColumn('")
                        .append(column.getHumpName())
                        .append("', '")
                        .append(escapeJs(resolveFieldLabel(column)))
                        .append("', 120),\n");
                continue;
            }
            if (isDisplayTextColumn(column)) {
                sb.append("    createTextColumn('")
                        .append(column.getHumpName())
@@ -695,6 +695,14 @@
                        .append("),\n");
                continue;
            }
            if (isNumericColumn(column)) {
                sb.append("    createNumberColumn('")
                        .append(column.getHumpName())
                        .append("', '")
                        .append(escapeJs(resolveFieldLabel(column)))
                        .append("', 120),\n");
                continue;
            }
            sb.append("    createTextColumn('")
                    .append(column.getHumpName())
                    .append("', '")
@@ -703,7 +711,7 @@
                    .append(resolveTextColumnWidth(column))
                    .append("),\n");
        }
        return trimTrailingLineBreak(sb);
        return trimTrailingLineBreakKeepComma(sb);
    }
    private String buildExportRowContent() {
@@ -898,6 +906,16 @@
        return sb.toString();
    }
    private String trimTrailingLineBreakKeepComma(StringBuilder sb) {
        if (sb.length() == 0) {
            return "";
        }
        while (sb.length() > 0 && (sb.charAt(sb.length() - 1) == '\n' || sb.charAt(sb.length() - 1) == '\r')) {
            sb.deleteCharAt(sb.length() - 1);
        }
        return sb.toString();
    }
    private String safeText(String value) {
        return value == null ? "" : value.trim();
    }
rsf-server/src/test/java/com/vincent/rsf/server/common/RsfDesignGeneratorRegressionTest.java
New file
@@ -0,0 +1,503 @@
package com.vincent.rsf.server.common;
import com.vincent.rsf.framework.generators.ReactGenerator;
import com.vincent.rsf.framework.generators.RsfDesignGenerator;
import com.vincent.rsf.framework.generators.constant.SqlOsType;
import com.vincent.rsf.framework.generators.domain.Column;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RsfDesignGeneratorRegressionTest {
    @TempDir
    Path tempDir;
    @Test
    void buildFrontendArtifactsKeepsDisplayColumnsCommaAndNestedApiPath() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_test",
                "系统测试表",
                "test",
                "Test",
                "system/test",
                "system/test",
                buildColumns()
        );
        invokePrivate(generator, "buildFrontendArtifacts");
        Path tableColumnsFile = tempDir.resolve("src/views/system/test/testTable.columns.js");
        Path pageHelpersFile = tempDir.resolve("src/views/system/test/testPage.helpers.js");
        Path apiFile = tempDir.resolve("src/api/system/test.js");
        String tableColumnsContent = Files.readString(tableColumnsFile, StandardCharsets.UTF_8);
        String pageHelpersContent = Files.readString(pageHelpersFile, StandardCharsets.UTF_8);
        assertAll(
                () -> assertTrue(Files.exists(apiFile), "嵌套 api 模块路径应生成到 src/api/system/test.js"),
                () -> assertTrue(tableColumnsContent.contains("createTextColumn('levelText', '等级', 160),"), "数值枚举列应显示为 levelText"),
                () -> assertTrue(tableColumnsContent.contains("createTextColumn('roleIdText', '角色', 160),"), "数值外键列应显示为 roleIdText"),
                () -> assertFalse(tableColumnsContent.contains("createNumberColumn('level',"), "数值枚举列不应再走原始数字列"),
                () -> assertFalse(tableColumnsContent.contains("createNumberColumn('roleId',"), "数值外键列不应再走原始数字列"),
                () -> assertTrue(
                        tableColumnsContent.contains("createTextColumn('memo', '备注', 220),\r\n    {")
                                || tableColumnsContent.contains("createTextColumn('memo', '备注', 220),\n    {"),
                        "最后一个业务列和操作列之间应保留逗号"
                ),
                () -> assertTrue(pageHelpersContent.contains("{ source: 'levelText', label: '等级' }"), "报表列应包含 levelText"),
                () -> assertTrue(pageHelpersContent.contains("{ source: 'roleIdText', label: '角色' }"), "报表列应包含 roleIdText")
        );
    }
    @Test
    void buildFrontendArtifactsKeepsBooleanAndStatusTextMappingForPrintExport() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_switch",
                "系统开关表",
                "switchItem",
                "SwitchItem",
                "system/switch-item",
                "system/switch-item",
                buildBooleanColumns()
        );
        invokePrivate(generator, "buildFrontendArtifacts");
        Path tableColumnsFile = tempDir.resolve("src/views/system/switch-item/switchItemTable.columns.js");
        Path pageHelpersFile = tempDir.resolve("src/views/system/switch-item/switchItemPage.helpers.js");
        String tableColumnsContent = Files.readString(tableColumnsFile, StandardCharsets.UTF_8);
        String pageHelpersContent = Files.readString(pageHelpersFile, StandardCharsets.UTF_8);
        assertAll(
                () -> assertTrue(tableColumnsContent.contains("createTextColumn('enabledText', '是否启用', 160),"), "布尔字段列表列应显示为 enabledText"),
                () -> assertTrue(tableColumnsContent.contains("createTagColumn('status', '状态', 120"), "status 字段应继续走标签列"),
                () -> assertTrue(pageHelpersContent.contains("enabledText: formatBooleanText(toOptionalBoolean(record.enabled))"), "布尔字段应生成 formatBooleanText 映射"),
                () -> assertTrue(pageHelpersContent.contains("{ source: 'enabledText', label: '是否启用' }"), "布尔字段报表列应使用 enabledText"),
                () -> assertTrue(pageHelpersContent.contains("{ source: 'statusText', label: '状态' }"), "status 报表列应使用 statusText"),
                () -> assertTrue(pageHelpersContent.contains("status: 'statusText'"), "status 导出列别名应映射到 statusText")
        );
    }
    @Test
    void buildFrontendArtifactsSupportsDeepNestedViewAndApiModules() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_deep_case",
                "深层目录测试表",
                "deepCase",
                "DeepCase",
                "basic-info/warehouse/deep-case",
                "system/admin/deep-case",
                buildColumns()
        );
        invokePrivate(generator, "buildFrontendArtifacts");
        assertAll(
                () -> assertTrue(Files.exists(tempDir.resolve("src/views/basic-info/warehouse/deep-case/index.vue")), "深层 view 路径应生成 index.vue"),
                () -> assertTrue(Files.exists(tempDir.resolve("src/views/basic-info/warehouse/deep-case/deepCasePage.helpers.js")), "深层 view 路径应生成 helpers"),
                () -> assertTrue(Files.exists(tempDir.resolve("src/views/basic-info/warehouse/deep-case/deepCaseTable.columns.js")), "深层 view 路径应生成列文件"),
                () -> assertTrue(Files.exists(tempDir.resolve("src/views/basic-info/warehouse/deep-case/modules/deep-case-search.vue")), "深层 view 路径应生成 search 组件"),
                () -> assertTrue(Files.exists(tempDir.resolve("src/views/basic-info/warehouse/deep-case/modules/deep-case-edit-dialog.vue")), "深层 view 路径应生成 edit dialog"),
                () -> assertTrue(Files.exists(tempDir.resolve("src/api/system/admin/deep-case.js")), "深层 api 模块路径应生成到正确目录")
        );
    }
    @Test
    void buildFrontendArtifactsKeepsFormDefaultsPayloadAndRulesAligned() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_default_case",
                "默认值测试表",
                "defaultCase",
                "DefaultCase",
                "system/default-case",
                "system/default-case",
                buildDefaultCaseColumns()
        );
        invokePrivate(generator, "buildFrontendArtifacts");
        Path pageHelpersFile = tempDir.resolve("src/views/system/default-case/defaultCasePage.helpers.js");
        Path editDialogFile = tempDir.resolve("src/views/system/default-case/modules/default-case-edit-dialog.vue");
        String pageHelpersContent = Files.readString(pageHelpersFile, StandardCharsets.UTF_8);
        String editDialogContent = Files.readString(editDialogFile, StandardCharsets.UTF_8);
        assertAll(
                () -> assertTrue(pageHelpersContent.contains("status: 1,"), "状态字段表单默认值应使用首个枚举值"),
                () -> assertTrue(pageHelpersContent.contains("level: void 0,"), "数字字段表单默认值应为 void 0"),
                () -> assertTrue(pageHelpersContent.contains("enabled: void 0,"), "布尔字段表单默认值应为 void 0"),
                () -> assertTrue(pageHelpersContent.contains("status: hasValue(record.status) ? toOptionalNumber(record.status) : 1,"), "状态字段弹窗模型应保留默认回退值"),
                () -> assertTrue(pageHelpersContent.contains("...buildNumberField('status', formData.status),"), "状态字段保存 payload 应走数字字段构造"),
                () -> assertTrue(pageHelpersContent.contains("...(hasValue(formData.enabled) ? { enabled: toOptionalBoolean(formData.enabled) } : {}),"), "布尔字段保存 payload 应走布尔转换"),
                () -> assertTrue(editDialogContent.contains("createInputFormItem('名称', 'name', '请输入名称'),"), "普通文本字段应生成输入框"),
                () -> assertTrue(editDialogContent.contains("createInputFormItem('等级', 'level', '请输入等级', { type: 'number' }),"), "数字字段应生成 number 输入框"),
                () -> assertTrue(editDialogContent.contains("createSelectFormItem('是否启用', 'enabled', '请选择是否启用', getDefaultCaseFieldOptions('enabled')),") , "布尔字段应生成下拉项"),
                () -> assertTrue(editDialogContent.contains("createSelectFormItem('状态', 'status', '请选择状态', getDefaultCaseFieldOptions('status')),") , "枚举状态字段应生成下拉项"),
                () -> assertTrue(editDialogContent.contains("name: [{ required: true, message: '请输入名称', trigger: 'blur' }],"), "文本必填规则应使用 blur/请输入"),
                () -> assertTrue(editDialogContent.contains("status: [{ required: true, message: '请选择状态', trigger: 'change' }]"), "选择类必填规则应使用 change/请选择")
        );
    }
    @Test
    void buildFrontendArtifactsKeepsSearchItemsAndQueryParamsAligned() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_search_case",
                "搜索测试表",
                "searchCase",
                "SearchCase",
                "system/search-case",
                "system/search-case",
                buildSearchCaseColumns()
        );
        invokePrivate(generator, "buildFrontendArtifacts");
        Path pageHelpersFile = tempDir.resolve("src/views/system/search-case/searchCasePage.helpers.js");
        Path searchFile = tempDir.resolve("src/views/system/search-case/modules/search-case-search.vue");
        String pageHelpersContent = Files.readString(pageHelpersFile, StandardCharsets.UTF_8);
        String searchContent = Files.readString(searchFile, StandardCharsets.UTF_8);
        assertAll(
                () -> assertTrue(pageHelpersContent.contains("keyword: '',"), "文本搜索字段应出现在搜索状态中"),
                () -> assertTrue(pageHelpersContent.contains("level: '',"), "数字搜索字段应出现在搜索状态中"),
                () -> assertTrue(pageHelpersContent.contains("enabled: '',"), "布尔搜索字段应出现在搜索状态中"),
                () -> assertTrue(pageHelpersContent.contains("status: ''"), "枚举搜索字段应出现在搜索状态中"),
                () -> assertFalse(pageHelpersContent.contains("createTime: '',"), "日期字段不应进入搜索状态"),
                () -> assertFalse(pageHelpersContent.contains("deleted: '',"), "托管字段不应进入搜索状态"),
                () -> assertTrue(pageHelpersContent.contains("keyword: normalizeText(params.keyword),"), "文本搜索参数应走 normalizeText"),
                () -> assertTrue(pageHelpersContent.contains("level: toOptionalNumber(params.level),"), "数字搜索参数应走 toOptionalNumber"),
                () -> assertTrue(pageHelpersContent.contains("enabled: toOptionalBoolean(params.enabled),"), "布尔搜索参数应走 toOptionalBoolean"),
                () -> assertTrue(pageHelpersContent.contains("status: toOptionalNumber(params.status)"), "枚举数字搜索参数应走 toOptionalNumber"),
                () -> assertTrue(pageHelpersContent.contains("current: params.current || 1,"), "分页参数应统一 current 默认值"),
                () -> assertTrue(pageHelpersContent.contains("pageSize: params.pageSize || params.size || 20,"), "分页参数应兼容 size/pageSize"),
                () -> assertTrue(searchContent.contains("createInputSearchItem('关键字', 'condition', '请输入搜索测试表关键字'),"), "搜索栏应始终包含 condition 关键字搜索"),
                () -> assertTrue(searchContent.contains("createInputSearchItem('关键字字段', 'keyword', '请输入关键字字段'),"), "文本搜索字段应生成输入框"),
                () -> assertTrue(searchContent.contains("createInputSearchItem('等级', 'level', '请输入等级'),"), "数字搜索字段当前应生成输入框"),
                () -> assertTrue(searchContent.contains("createSelectSearchItem('是否启用', 'enabled', '请选择是否启用', getSearchCaseFieldOptions('enabled')),") , "布尔搜索字段应生成下拉项"),
                () -> assertTrue(searchContent.contains("createSelectSearchItem('状态', 'status', '请选择状态', getSearchCaseFieldOptions('status'))") , "枚举搜索字段应生成下拉项"),
                () -> assertFalse(searchContent.contains("createTime"), "日期字段不应生成搜索项"),
                () -> assertFalse(searchContent.contains("deleted"), "托管字段不应生成搜索项")
        );
    }
    @Test
    void writeControllerTemplateKeepsCrudAndExportContractAligned() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_controller_case",
                "控制器测试表",
                "controllerCase",
                "ControllerCase",
                "system/controller-case",
                "system/controller-case",
                buildControllerCaseColumns()
        );
        generator.backendPrefixPath = tempDir.toString().replace("\\", "/");
        String controllerDirectory = (String) invokePrivate(generator, "resolveControllerDirectory");
        invokePrivate(
                generator,
                "writeTemplate",
                new Class<?>[]{String.class, String.class, String.class},
                new Object[]{"Controller", controllerDirectory, "ControllerCaseController.java"}
        );
        Path controllerFile = tempDir.resolve("src/main/java/com/vincent/rsf/server/system/controller/ControllerCaseController.java");
        String controllerContent = Files.readString(controllerFile, StandardCharsets.UTF_8);
        assertAll(
                () -> assertTrue(controllerContent.contains("public class ControllerCaseController extends BaseController"), "控制器类名应和实体保持一致"),
                () -> assertTrue(controllerContent.contains("private ControllerCaseService controllerCaseService;"), "控制器应注入 service"),
                () -> assertTrue(controllerContent.contains("private ListExportService listExportService;"), "控制器应注入导出服务"),
                () -> assertTrue(controllerContent.contains("@PostMapping(\"/controllerCase/page\")"), "分页接口路径应保持 page"),
                () -> assertTrue(controllerContent.contains("@PostMapping(\"/controllerCase/save\")"), "新增接口路径应保持 save"),
                () -> assertTrue(controllerContent.contains("@PostMapping(\"/controllerCase/update\")"), "更新接口路径应保持 update"),
                () -> assertTrue(controllerContent.contains("@PostMapping(\"/controllerCase/remove/{ids}\")"), "删除接口路径应保持 remove"),
                () -> assertTrue(controllerContent.contains("@PostMapping(\"/controllerCase/query\")"), "查询接口路径应保持 query"),
                () -> assertTrue(controllerContent.contains("@PostMapping(\"/controllerCase/export\")"), "导出接口路径应保持 export"),
                () -> assertTrue(controllerContent.contains("listExportService.export(map, exportMap -> buildParam(exportMap, BaseParam.class), controllerCaseExportHandler, response);"), "导出接口应继续走 ListExportService"),
                () -> assertTrue(controllerContent.contains("controllerCase.setCreateBy(getLoginUserId());"), "新增初始化应补 createBy"),
                () -> assertTrue(controllerContent.contains("controllerCase.setCreateTime(new Date());"), "新增初始化应补 createTime"),
                () -> assertTrue(controllerContent.contains("controllerCase.setUpdateBy(getLoginUserId());"), "新增和更新初始化都应补 updateBy"),
                () -> assertTrue(controllerContent.contains("controllerCase.setUpdateTime(new Date());"), "新增和更新初始化都应补 updateTime"),
                () -> assertTrue(controllerContent.contains("row.put(\"statusText\", record.getStatus$());"), "导出行应输出 statusText"),
                () -> assertTrue(controllerContent.contains("row.put(\"ownerIdText\", record.getOwnerId$());"), "导出行应输出外键文本字段"),
                () -> assertTrue(controllerContent.contains("wrapper.like(ControllerCase::getName, condition);"), "query 接口应按主字段模糊查询"),
                () -> assertTrue(controllerContent.contains("new KeyValVo(item.getId(), item.getName())"), "query 接口应返回主键和主字段")
        );
    }
    @Test
    void buildFrontendArtifactsKeepsApiRequestContractAligned() throws Exception {
        RsfDesignGenerator generator = createGenerator(
                "sys_api_case",
                "接口测试表",
                "apiCase",
                "ApiCase",
                "system/api-case",
                "system/api-case",
                buildColumns()
        );
        invokePrivate(generator, "buildFrontendArtifacts");
        Path apiFile = tempDir.resolve("src/api/system/api-case.js");
        String apiContent = Files.readString(apiFile, StandardCharsets.UTF_8);
        assertAll(
                () -> assertTrue(apiContent.contains("function normalizeIds(ids) {"), "api 文件应包含 ids 归一化工具"),
                () -> assertTrue(apiContent.contains("export function fetchApiCasePage(params = {}) {"), "应生成 page 请求函数"),
                () -> assertTrue(apiContent.contains("url: '/apiCase/page'"), "page 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchApiCaseList(params = {}) {"), "应生成 list 请求函数"),
                () -> assertTrue(apiContent.contains("url: '/apiCase/list'"), "list 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchGetApiCaseDetail(id) {"), "应生成 detail 请求函数"),
                () -> assertTrue(apiContent.contains("url: `/apiCase/${id}`"), "detail 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchGetApiCaseMany(ids) {"), "应生成 many 请求函数"),
                () -> assertTrue(apiContent.contains("url: `/apiCase/many/${normalizeIds(ids)}`"), "many 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchSaveApiCase(params = {}) {"), "应生成 save 请求函数"),
                () -> assertTrue(apiContent.contains("url: '/apiCase/save'"), "save 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchUpdateApiCase(params = {}) {"), "应生成 update 请求函数"),
                () -> assertTrue(apiContent.contains("url: '/apiCase/update'"), "update 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchDeleteApiCase(ids) {"), "应生成 delete 请求函数"),
                () -> assertTrue(apiContent.contains("url: `/apiCase/remove/${normalizeIds(ids)}`"), "delete 请求路径应正确"),
                () -> assertTrue(apiContent.contains("export function fetchApiCaseQuery(condition = '') {"), "应生成 query 请求函数"),
                () -> assertTrue(apiContent.contains("url: '/apiCase/query'"), "query 请求路径应正确"),
                () -> assertTrue(apiContent.contains("condition: normalizeText(condition)"), "query 请求应对 condition 做 trim"),
                () -> assertTrue(apiContent.contains("export async function fetchExportApiCaseReport(payload = {}, options = {}) {"), "应生成 export 请求函数"),
                () -> assertTrue(apiContent.contains("fetch(`${import.meta.env.VITE_API_URL}/apiCase/export`"), "export 应直接请求完整导出地址"),
                () -> assertTrue(apiContent.contains("method: 'POST'"), "export 请求应使用 POST"),
                () -> assertTrue(apiContent.contains("body: JSON.stringify(payload)"), "export 请求应发送 JSON body")
        );
    }
    @Test
    void writeBackendArtifactsKeepEntityServiceMapperXmlAndSqlAligned() throws Exception {
        ReactGenerator generator = createReactGenerator(
                "sys_backend_case",
                "后端测试表",
                "backendCase",
                "BackendCase",
                buildBackendCaseColumns()
        );
        writeReactTemplate(generator, "Entity", tempDir.resolve("src/main/java/com/vincent/rsf/server/system/entity").toString() + "/", "BackendCase.java");
        writeReactTemplate(generator, "Service", tempDir.resolve("src/main/java/com/vincent/rsf/server/system/service").toString() + "/", "BackendCaseService.java");
        writeReactTemplate(generator, "ServiceImpl", tempDir.resolve("src/main/java/com/vincent/rsf/server/system/service/impl").toString() + "/", "BackendCaseServiceImpl.java");
        writeReactTemplate(generator, "Mapper", tempDir.resolve("src/main/java/com/vincent/rsf/server/system/mapper").toString() + "/", "BackendCaseMapper.java");
        writeReactTemplate(generator, "Xml", tempDir.resolve("src/main/resources/mapper/system").toString() + "/", "BackendCaseMapper.xml");
        writeReactTemplate(generator, "Sql", tempDir.resolve("src/main/java").toString() + "/", "backendCase.sql");
        Charset systemCharset = Charset.defaultCharset();
        String entityContent = Files.readString(tempDir.resolve("src/main/java/com/vincent/rsf/server/system/entity/BackendCase.java"), systemCharset);
        String serviceContent = Files.readString(tempDir.resolve("src/main/java/com/vincent/rsf/server/system/service/BackendCaseService.java"), systemCharset);
        String serviceImplContent = Files.readString(tempDir.resolve("src/main/java/com/vincent/rsf/server/system/service/impl/BackendCaseServiceImpl.java"), systemCharset);
        String mapperContent = Files.readString(tempDir.resolve("src/main/java/com/vincent/rsf/server/system/mapper/BackendCaseMapper.java"), systemCharset);
        String xmlContent = Files.readString(tempDir.resolve("src/main/resources/mapper/system/BackendCaseMapper.xml"), systemCharset);
        String sqlContent = Files.readString(tempDir.resolve("src/main/java/backendCase.sql"), systemCharset);
        assertAll(
                () -> assertTrue(entityContent.contains("@TableName(\"sys_backend_case\")"), "实体应绑定真实表名"),
                () -> assertTrue(entityContent.contains("private Long id;"), "实体应生成主键字段"),
                () -> assertTrue(entityContent.contains("private String name;"), "实体应生成普通文本字段"),
                () -> assertTrue(entityContent.contains("private Long ownerId;"), "实体应生成外键字段"),
                () -> assertTrue(entityContent.contains("private Integer status;"), "实体应生成状态字段"),
                () -> assertTrue(entityContent.contains("@TableLogic"), "实体应为 deleted 字段添加逻辑删除注解"),
                () -> assertTrue(entityContent.contains("@DateTimeFormat(pattern=\"yyyy-MM-dd HH:mm:ss\")"), "实体应为日期字段添加时间格式注解"),
                () -> assertTrue(entityContent.contains("public Boolean getStatusBool(){"), "实体应保留 statusBool 便捷方法"),
                () -> assertTrue(serviceContent.contains("public interface BackendCaseService extends IService<BackendCase>"), "Service 接口应继承 IService"),
                () -> assertTrue(serviceImplContent.contains("public class BackendCaseServiceImpl extends ServiceImpl<BackendCaseMapper, BackendCase> implements BackendCaseService"), "ServiceImpl 应绑定 mapper 和 entity"),
                () -> assertTrue(serviceImplContent.contains("@Service(\"backendCaseService\")"), "ServiceImpl bean 名应使用小驼峰"),
                () -> assertTrue(mapperContent.contains("public interface BackendCaseMapper extends BaseMapper<BackendCase>"), "Mapper 应继承 BaseMapper"),
                () -> assertTrue(xmlContent.contains("<mapper namespace=\"com.vincent.rsf.server.system.mapper.BackendCaseMapper\">"), "Xml namespace 应指向正确 mapper"),
                () -> assertTrue(sqlContent.contains("insert into `sys_menu`"), "Sql 模板应生成菜单 SQL"),
                () -> assertTrue(sqlContent.contains("'system:backendCase:list'"), "Sql 模板应生成 list 权限"),
                () -> assertTrue(sqlContent.contains("'system:backendCase:save'"), "Sql 模板应生成 save 权限"),
                () -> assertTrue(sqlContent.contains("'system:backendCase:update'"), "Sql 模板应生成 update 权限"),
                () -> assertTrue(sqlContent.contains("'system:backendCase:remove'"), "Sql 模板应生成 remove 权限"),
                () -> assertTrue(sqlContent.contains("backendCase: 'BackendCase'"), "Sql 模板应生成 locale 菜单名"),
                () -> assertTrue(sqlContent.contains("import backendCase from './backendCase';"), "Sql 模板应生成资源导入片段")
        );
    }
    private List<Column> buildColumns() {
        Column id = new Column(null, "id", "Long", "ID", true, false, true, 20, false, SqlOsType.MYSQL);
        Column name = new Column(null, "name", "String", "名称(*)", false, false, true, 255, false, SqlOsType.MYSQL);
        Column level = new Column(null, "level", "Integer", "等级{1:高,0:低}", false, false, false, 11, false, SqlOsType.MYSQL);
        Column roleId = new Column(null, "role_id", "Long", "角色", false, false, false, 20, false, SqlOsType.MYSQL);
        Column status = new Column(null, "status", "Integer", "状态{1:正常,0:禁用}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column deleted = new Column(null, "deleted", "Integer", "是否删除{1:是,0:否}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column createTime = new Column(null, "create_time", "Date", "添加时间", false, false, false, null, false, SqlOsType.MYSQL);
        Column updateTime = new Column(null, "update_time", "Date", "修改时间", false, false, false, null, false, SqlOsType.MYSQL);
        Column memo = new Column(null, "memo", "String", "备注", false, false, false, 255, false, SqlOsType.MYSQL);
        roleId.setForeignKeyMajor("Name");
        return List.of(id, name, level, roleId, status, deleted, createTime, updateTime, memo);
    }
    private List<Column> buildBooleanColumns() {
        Column id = new Column(null, "id", "Long", "ID", true, false, true, 20, false, SqlOsType.MYSQL);
        Column name = new Column(null, "name", "String", "名称(*)", false, false, true, 255, false, SqlOsType.MYSQL);
        Column enabled = new Column(null, "enabled", "Boolean", "是否启用", false, false, false, 1, false, SqlOsType.MYSQL);
        Column status = new Column(null, "status", "Integer", "状态{1:正常,0:禁用}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column deleted = new Column(null, "deleted", "Integer", "是否删除{1:是,0:否}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column memo = new Column(null, "memo", "String", "备注", false, false, false, 255, false, SqlOsType.MYSQL);
        return List.of(id, name, enabled, status, deleted, memo);
    }
    private List<Column> buildDefaultCaseColumns() {
        Column id = new Column(null, "id", "Long", "ID", true, false, true, 20, false, SqlOsType.MYSQL);
        Column name = new Column(null, "name", "String", "名称(*)", false, false, true, 255, false, SqlOsType.MYSQL);
        Column level = new Column(null, "level", "Integer", "等级", false, false, false, 11, false, SqlOsType.MYSQL);
        Column enabled = new Column(null, "enabled", "Boolean", "是否启用", false, false, false, 1, false, SqlOsType.MYSQL);
        Column status = new Column(null, "status", "Integer", "状态{1:正常,0:禁用}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column deleted = new Column(null, "deleted", "Integer", "是否删除{1:是,0:否}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column memo = new Column(null, "memo", "String", "备注", false, false, false, 255, false, SqlOsType.MYSQL);
        return List.of(id, name, level, enabled, status, deleted, memo);
    }
    private List<Column> buildSearchCaseColumns() {
        Column id = new Column(null, "id", "Long", "ID", true, false, true, 20, false, SqlOsType.MYSQL);
        Column keyword = new Column(null, "keyword", "String", "关键字字段", false, false, false, 255, false, SqlOsType.MYSQL);
        Column level = new Column(null, "level", "Integer", "等级", false, false, false, 11, false, SqlOsType.MYSQL);
        Column enabled = new Column(null, "enabled", "Boolean", "是否启用", false, false, false, 1, false, SqlOsType.MYSQL);
        Column status = new Column(null, "status", "Integer", "状态{1:正常,0:禁用}", false, false, false, 1, false, SqlOsType.MYSQL);
        Column createTime = new Column(null, "create_time", "Date", "添加时间", false, false, false, null, false, SqlOsType.MYSQL);
        Column deleted = new Column(null, "deleted", "Integer", "是否删除{1:是,0:否}", false, false, true, 1, false, SqlOsType.MYSQL);
        return List.of(id, keyword, level, enabled, status, createTime, deleted);
    }
    private List<Column> buildControllerCaseColumns() {
        Column id = new Column(null, "id", "Long", "ID", true, false, true, 20, false, SqlOsType.MYSQL);
        Column name = new Column(null, "name", "String", "名称(*)", false, false, true, 255, false, SqlOsType.MYSQL);
        Column ownerId = new Column(null, "owner_id", "Long", "负责人", false, false, false, 20, false, SqlOsType.MYSQL);
        Column status = new Column(null, "status", "Integer", "状态{1:正常,0:禁用}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column createBy = new Column(null, "create_by", "Long", "创建人", false, false, false, 20, false, SqlOsType.MYSQL);
        Column createTime = new Column(null, "create_time", "Date", "创建时间", false, false, false, null, false, SqlOsType.MYSQL);
        Column updateBy = new Column(null, "update_by", "Long", "更新人", false, false, false, 20, false, SqlOsType.MYSQL);
        Column updateTime = new Column(null, "update_time", "Date", "更新时间", false, false, false, null, false, SqlOsType.MYSQL);
        ownerId.setForeignKeyMajor("Name");
        return List.of(id, name, ownerId, status, createBy, createTime, updateBy, updateTime);
    }
    private List<Column> buildBackendCaseColumns() {
        Column id = new Column(null, "id", "Long", "ID", true, false, true, 20, false, SqlOsType.MYSQL);
        Column name = new Column(null, "name", "String", "名称(*)", false, false, true, 255, false, SqlOsType.MYSQL);
        Column ownerId = new Column(null, "owner_id", "Long", "负责人", false, false, false, 20, false, SqlOsType.MYSQL);
        Column status = new Column(null, "status", "Integer", "状态{1:正常,0:禁用}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column deleted = new Column(null, "deleted", "Integer", "是否删除{1:是,0:否}", false, false, true, 1, false, SqlOsType.MYSQL);
        Column createTime = new Column(null, "create_time", "Date", "创建时间", false, false, false, null, false, SqlOsType.MYSQL);
        ownerId.setForeignKey("User");
        ownerId.setForeignKeyMajor("Name");
        return List.of(id, name, ownerId, status, deleted, createTime);
    }
    private RsfDesignGenerator createGenerator(
            String table,
            String tableDesc,
            String simpleEntityName,
            String fullEntityName,
            String frontendViewPath,
            String frontendApiModule,
            List<Column> columns
    ) throws Exception {
        RsfDesignGenerator generator = new RsfDesignGenerator();
        generator.table = table;
        generator.tableDesc = tableDesc;
        generator.packagePath = "com.vincent.rsf.server.system";
        generator.frontendPrefixPath = tempDir.toString().replace("\\", "/");
        generator.frontendViewPath = frontendViewPath;
        generator.frontendApiModule = frontendApiModule;
        generator.sqlOsType = SqlOsType.MYSQL;
        setField(generator, "columns", columns);
        setField(generator, "fullEntityName", fullEntityName);
        setField(generator, "simpleEntityName", simpleEntityName);
        setField(generator, "kebabEntityName", toKebab(simpleEntityName));
        setField(generator, "constantPrefix", simpleEntityName.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase());
        setField(generator, "primaryKeyColumn", "id");
        setField(generator, "majorColumn", "name");
        setField(generator, "itemName", "system");
        setField(generator, "normalizedFrontendViewPath", frontendViewPath);
        setField(generator, "normalizedFrontendApiModule", frontendApiModule);
        return generator;
    }
    private String toKebab(String value) {
        return value.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase();
    }
    private ReactGenerator createReactGenerator(
            String table,
            String tableDesc,
            String simpleEntityName,
            String fullEntityName,
            List<Column> columns
    ) throws Exception {
        ReactGenerator generator = new ReactGenerator();
        generator.table = table;
        generator.tableDesc = tableDesc;
        generator.packagePath = "com.vincent.rsf.server.system";
        generator.backendPrefixPath = tempDir.toString().replace("\\", "/") + "/";
        generator.frontendPrefixPath = tempDir.toString().replace("\\", "/");
        setField(generator, "columns", columns);
        setField(generator, "fullEntityName", fullEntityName);
        setField(generator, "simpleEntityName", simpleEntityName);
        setField(generator, "itemName", "system");
        setField(generator, "systemPackagePath", "com.vincent.rsf.server.system");
        setField(generator, "systemPackage", "com.vincent.rsf.server.system");
        setField(generator, "entityContent", invokePrivate(generator, "createEntityMsg"));
        setField(generator, "primaryKeyColumn", invokePrivate(generator, "createPrimaryMsg"));
        setField(generator, "majorColumn", invokePrivate(generator, "createMajorMsg"));
        setField(generator, "reactLocaleContent", invokePrivate(generator, "createReactLocaleContent"));
        return generator;
    }
    private void writeReactTemplate(ReactGenerator generator, String templateName, String directory, String fileName) throws Exception {
        String content = (String) invokePrivate(generator, "readFile", new Class<?>[]{String.class}, new Object[]{templateName});
        invokePrivate(
                generator,
                "writeFile",
                new Class<?>[]{String.class, String.class, String.class, String.class},
                new Object[]{content, directory.replace("\\", "/"), fileName, templateName}
        );
    }
    private void setField(Object target, String name, Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(target, value);
    }
    private Object invokePrivate(Object target, String name) throws Exception {
        Method method = target.getClass().getDeclaredMethod(name);
        method.setAccessible(true);
        return method.invoke(target);
    }
    private Object invokePrivate(Object target, String name, Class<?>[] parameterTypes, Object[] args) throws Exception {
        Method method = target.getClass().getDeclaredMethod(name, parameterTypes);
        method.setAccessible(true);
        return method.invoke(target, args);
    }
}