NewLife/cube-front

feat: 默认列表实现对接GetPage接口并展示列表数据
何炳宏 authored at 2026-06-05 15:32:19
c5d8da8
Tree
1 Parent(s) e87e8a0
Summary: 8 changed files with 664 additions and 108 deletions.
Modified +1 -12
Modified +59 -2
Modified +4 -4
Modified +9 -9
Modified +20 -7
Modified +8 -8
Modified +170 -25
Modified +393 -41
Modified +1 -12
diff --git a/core/utils/menuRoutes.ts b/core/utils/menuRoutes.ts
index 4e91c28..1844bad 100644
--- a/core/utils/menuRoutes.ts
+++ b/core/utils/menuRoutes.ts
@@ -1,6 +1,6 @@
 import type { Router } from 'vue-router';
 import type { FlatMenuItem } from 'cube-front/core/stores/menu';
-import { normalizeMenuUrl } from './url';
+import { normalizeMenuUrl, toKebabCase } from './url';
 
 /**
  * 视图组件缓存
@@ -31,17 +31,6 @@ const normalizedModulePaths = Object.keys(allViewModules).map((p) =>
 );
 
 /**
- * 将 PascalCase/camelCase 转为短横线风格
- * 例: MyDeviceName → my-device-name
- */
-function toKebabCase(str: string): string {
-  return str
-    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
-    .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
-    .toLowerCase();
-}
-
-/**
  * 根据路由路径解析对应的视图组件。
  *
  * 约定规则:
Modified +59 -2
diff --git a/core/utils/url.ts b/core/utils/url.ts
index de706f4..7780ad5 100644
--- a/core/utils/url.ts
+++ b/core/utils/url.ts
@@ -1,10 +1,35 @@
 import qs from 'query-string';
 
 /**
+ * 翻转字符串首字母大小写。
+ * PascalCase 转 camelCase(如 `CreateUserID` → `createUserID`),
+ * camelCase 转 PascalCase(如 `createUserID` → `CreateUserID`)。
+ * 用于容错后端 JSON 序列化时大小写不匹配的情况。
+ */
+export function toPascalAndCamel(str: string): string {
+  if (!str) return str;
+  return str.charAt(0).toUpperCase() === str.charAt(0)
+    ? str.charAt(0).toLowerCase() + str.slice(1)
+    : str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+/**
+ * 将短横线命名转为大驼峰命名
+ * 示例: my-device-name → MyDeviceName
+ */
+export function toPascalCase(str: string): string {
+  if (!str) return str;
+  return str
+    .split('-')
+    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+    .join('');
+}
+
+/**
  * 将 PascalCase/camelCase 转为短横线风格
  * 例: MyDeviceName → my-device-name
  */
-export function toKebabCase(str: string): string {
+export function toKebabCase(str: string) {
   return str
     .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
     .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
@@ -40,6 +65,38 @@ export function normalizeMenuUrl(url: string): string {
   return query ? `${result}?${query}` : result;
 }
 
+/**
+ * 根据路由路径获取 api 前缀
+ * 示例:/device/device-profile → /Device/DeviceProfile
+ */
+export function routeToApiPrefix(path: string): string {
+  return '/' + path.split('/').filter(Boolean).map(toPascalCase).join('/');
+}
+
+/**
+ * 从数据对象中取值,先尝试 `data[key]` 直接获取,
+ * 取不到时通过 `toPascalAndCamel(key)` 翻转首字母再取一次,
+ * 如果 key 是全大写字母(如 ID、UUID),再尝试全小写(id、uuid),
+ * 容错后端 JSON 字段名大小写不匹配问题。
+ */
+export function getValueByKey(data: Record<string, unknown>, key: string): unknown {
+  if (key in data) return data[key];
+  // 翻转首字母再试(容错 PascalCase ↔ camelCase)
+  const flipped = toPascalAndCamel(key);
+  if (flipped !== key && flipped in data) return data[flipped];
+  // 如果 key 是全大写,转成全小写再试(如 ID → id, UUID → uuid)
+  if (key === key.toUpperCase() && key !== key.toLowerCase()) {
+    const lowerKey = key.toLowerCase();
+    if (lowerKey in data) return data[lowerKey];
+  }
+  // 如果 key 是全小写且包含字母,试试全大写(如 id → ID)
+  if (key === key.toLowerCase() && /[a-z]/.test(key)) {
+    const upperKey = key.toUpperCase();
+    if (upperKey in data) return data[upperKey];
+  }
+  return undefined;
+}
+
 export function isUrl(path: string) {
   /* eslint no-useless-escape:0 */
   const reg =
@@ -68,7 +125,7 @@ export function generateUrlWithGetParam(url: string, params: {}) {
  * @param param
  * @returns {String}
  */
-export function getUrlParam(param: { [x: string]: any }) {
+export function getUrlParam(param: { [x: string]: any; }) {
   let on = true;
   let result = '';
   for (const item in param) {
Modified +4 -4
diff --git a/core/views/components/ListPagination.vue b/core/views/components/ListPagination.vue
index 6986693..7a9a58b 100644
--- a/core/views/components/ListPagination.vue
+++ b/core/views/components/ListPagination.vue
@@ -75,7 +75,7 @@ function handleSizeChange(size: number) {
   :deep(.el-pagination__editor.el-input .el-input__wrapper) {
     border-radius: 6px;
     box-shadow: 0 0 0 1px var(--bd) inset;
-    background: #fff;
+    background: var(--el-fill-color-blank);
   }
 
   :deep(.btn-prev),
@@ -85,7 +85,7 @@ function handleSizeChange(size: number) {
     height: 32px;
     border-radius: 6px;
     border: 1px solid var(--bd);
-    background: #fff;
+    background: var(--el-fill-color-blank);
     color: var(--t2);
     transition:
       background 0.13s var(--ease),
@@ -96,7 +96,7 @@ function handleSizeChange(size: number) {
   :deep(.btn-prev:disabled),
   :deep(.btn-next:disabled) {
     opacity: 0.4;
-    background: #fff;
+    background: var(--el-disabled-bg-color);
     border-color: var(--bd);
   }
 
@@ -111,7 +111,7 @@ function handleSizeChange(size: number) {
   :deep(.el-pager li.is-active) {
     background: var(--ac);
     border-color: var(--ac);
-    color: #fff;
+    color: var(--text-inverse);
   }
 }
 
Modified +9 -9
diff --git a/core/views/components/ListSearchBar.vue b/core/views/components/ListSearchBar.vue
index a9af48c..8c2edf3 100644
--- a/core/views/components/ListSearchBar.vue
+++ b/core/views/components/ListSearchBar.vue
@@ -265,7 +265,7 @@ function toggleCollapse() {
 
   :deep(.el-input__wrapper.is-focus),
   :deep(.el-select__wrapper.is-focused) {
-    background: #fff;
+    background: var(--el-fill-color-blank);
     box-shadow:
       0 0 0 1px var(--ac) inset,
       0 0 0 3px rgba(29, 112, 64, 0.08);
@@ -304,7 +304,7 @@ function toggleCollapse() {
   }
 
   :deep(.el-input__wrapper.is-focus) {
-    background: #fff;
+    background: var(--el-fill-color-blank);
     box-shadow:
       0 0 0 1px var(--ac) inset,
       0 0 0 3px rgba(29, 112, 64, 0.08);
@@ -346,21 +346,21 @@ function toggleCollapse() {
   &--primary {
     --el-button-bg-color: var(--ac);
     --el-button-border-color: var(--ac);
-    --el-button-hover-bg-color: #165c32;
-    --el-button-hover-border-color: #165c32;
-    --el-button-active-bg-color: #124c2a;
-    --el-button-active-border-color: #124c2a;
-    --el-button-text-color: #fff;
+    --el-button-hover-bg-color: var(--ac-hover);
+    --el-button-hover-border-color: var(--ac-hover);
+    --el-button-active-bg-color: var(--ac);
+    --el-button-active-border-color: var(--ac);
+    --el-button-text-color: var(--text-inverse);
   }
 
   &--secondary {
-    --el-button-bg-color: #fff;
+    --el-button-bg-color: var(--el-fill-color-blank);
     --el-button-border-color: var(--bd);
     --el-button-text-color: var(--t2);
     --el-button-hover-bg-color: var(--ac-l);
     --el-button-hover-border-color: var(--ac-b);
     --el-button-hover-text-color: var(--ac);
-    --el-button-active-bg-color: #dcefe4;
+    --el-button-active-bg-color: var(--ac-b);
     --el-button-active-border-color: var(--ac-b);
     --el-button-active-text-color: var(--ac);
     margin-left: 0;
Modified +20 -7
diff --git a/core/views/components/ListTableContent.vue b/core/views/components/ListTableContent.vue
index 4d1a972..cd36e78 100644
--- a/core/views/components/ListTableContent.vue
+++ b/core/views/components/ListTableContent.vue
@@ -1,4 +1,5 @@
 <script setup lang="ts">
+import { getValueByKey } from 'cube-front/core/utils/url';
 interface Column {
   key: string;
   label: string;
@@ -31,6 +32,11 @@ const props = withDefaults(defineProps<Props>(), {
   selectable: false,
 });
 
+const emit = defineEmits<{
+  edit: [row: Record<string, unknown>];
+  delete: [row: Record<string, unknown>];
+}>();
+
 const STATUS_LABELS: Record<string, { text: string; cls: string }> = {
   active: { text: '正常', cls: 'ok' },
   disabled: { text: '禁用', cls: 'er' },
@@ -40,7 +46,7 @@ const STATUS_LABELS: Record<string, { text: string; cls: string }> = {
 
 function getCellValue(row: Record<string, unknown>, col: Column): string {
   if (col.render) return col.render(row);
-  return String(row[col.key] ?? '');
+  return String(getValueByKey(row, col.key) ?? '');
 }
 
 function getStatusInfo(val: string) {
@@ -78,14 +84,14 @@ function getSelectionColumnWidth() {
         show-overflow-tooltip
       >
         <template #default="scope">
-          <template v-if="col.key === 'status' && getStatusInfo(String(scope.row[col.key] ?? ''))">
+          <template v-if="col.key === 'status' && getStatusInfo(String(getValueByKey(scope.row, col.key) ?? ''))">
             <el-tag
               effect="plain"
               round
               disable-transitions
-              :class="`ltc-tag ltc-tag--${getStatusInfo(String(scope.row[col.key] ?? ''))!.cls}`"
+              :class="`ltc-tag ltc-tag--${getStatusInfo(String(getValueByKey(scope.row, col.key) ?? ''))!.cls}`"
             >
-              {{ getStatusInfo(String(scope.row[col.key] ?? ''))!.text }}
+              {{ getStatusInfo(String(getValueByKey(scope.row, col.key) ?? ''))!.text }}
             </el-tag>
           </template>
           <span v-else class="ltc-cell-text" :class="{ 'ltc-cell-text--mono': col.mono }">
@@ -94,6 +100,13 @@ function getSelectionColumnWidth() {
         </template>
       </el-table-column>
 
+      <el-table-column label="操作" width="120" fixed="right" align="center">
+        <template #default="scope">
+          <el-button type="primary" link @click="emit('edit', scope.row)">编辑</el-button>
+          <el-button type="danger" link @click="emit('delete', scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+
       <template #empty>
         <div class="ltc-empty">暂无数据</div>
       </template>
@@ -116,7 +129,7 @@ function getSelectionColumnWidth() {
   }
 
   :deep(.el-table__header-wrapper th.el-table__cell) {
-    background: #f8faf7;
+    background: var(--el-bg-color);
     border-bottom: 1px solid var(--bd);
     padding: 0;
   }
@@ -133,11 +146,11 @@ function getSelectionColumnWidth() {
 
   :deep(.el-table__body-wrapper td.el-table__cell) {
     padding: 0;
-    border-bottom: 1px solid #f0f4ee;
+    border-bottom: 1px solid var(--bd);
   }
 
   :deep(.el-table__body tr:hover > td.el-table__cell) {
-    background: #fafcf9;
+    background: var(--table-row-hover, var(--ac-l));
   }
 
   :deep(.el-table__body .cell) {
Modified +8 -8
diff --git a/core/views/components/ListToolbar.vue b/core/views/components/ListToolbar.vue
index 037332c..c1e1736 100644
--- a/core/views/components/ListToolbar.vue
+++ b/core/views/components/ListToolbar.vue
@@ -68,33 +68,33 @@ const emit = defineEmits<{
   &--primary {
     --el-button-bg-color: var(--ac);
     --el-button-border-color: var(--ac);
-    --el-button-hover-bg-color: #165c32;
-    --el-button-hover-border-color: #165c32;
-    --el-button-active-bg-color: #124c2a;
-    --el-button-active-border-color: #124c2a;
+    --el-button-hover-bg-color: var(--accent-hover);
+    --el-button-hover-border-color: var(--accent-hover);
+    --el-button-active-bg-color: var(--accent-hover);
+    --el-button-active-border-color: var(--accent-hover);
   }
 
   &--secondary {
-    --el-button-bg-color: #fff;
+    --el-button-bg-color: var(--el-fill-color-blank);
     --el-button-border-color: var(--bd);
     --el-button-text-color: var(--t2);
     --el-button-hover-bg-color: var(--ac-l);
     --el-button-hover-border-color: var(--ac-b);
     --el-button-hover-text-color: var(--ac);
-    --el-button-active-bg-color: #dcefe4;
+    --el-button-active-bg-color: var(--ac-b);
     --el-button-active-border-color: var(--ac-b);
     --el-button-active-text-color: var(--ac);
     margin-left: 0;
   }
 
   &--refresh {
-    --el-button-bg-color: #fff;
+    --el-button-bg-color: var(--el-fill-color-blank);
     --el-button-border-color: var(--bd);
     --el-button-text-color: var(--t2);
     --el-button-hover-bg-color: var(--ac-l);
     --el-button-hover-border-color: var(--ac-b);
     --el-button-hover-text-color: var(--ac);
-    --el-button-active-bg-color: #dcefe4;
+    --el-button-active-bg-color: var(--ac-b);
     --el-button-active-border-color: var(--ac-b);
     --el-button-active-text-color: var(--ac);
     margin-left: 0;
Modified +170 -25
diff --git a/core/views/form.vue b/core/views/form.vue
index 5cbd4ec..f417618 100644
--- a/core/views/form.vue
+++ b/core/views/form.vue
@@ -1,7 +1,9 @@
 <script setup lang="ts">
-import { inject, provide, defineAsyncComponent } from 'vue';
-import { useRoute } from 'vue-router';
+import { inject, provide, defineAsyncComponent, ref, computed, onMounted } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage } from 'element-plus';
 import type { Component } from 'vue';
+import request from 'cube-front/core/utils/request';
 import {
   FormPageHeaderKey,
   FormContentKey,
@@ -13,6 +15,20 @@ import {
 import DefaultFormPageHeader from 'cube-front/core/views/components/FormPageHeader.vue';
 import DefaultFormContent from 'cube-front/core/views/components/FormContent.vue';
 import DefaultFormActions from 'cube-front/core/views/components/FormActions.vue';
+import { routeToApiPrefix } from '../utils/url';
+
+interface BackendField {
+  name: string;
+  displayName: string;
+  description?: string;
+  typeName: string;
+  itemType?: string;
+  length?: number;
+  nullable?: boolean;
+  primaryKey?: boolean;
+  readOnly?: boolean;
+  mapField?: string;
+}
 
 interface FormField {
   key: string;
@@ -21,7 +37,7 @@ interface FormField {
   required?: boolean;
   fullWidth?: boolean;
   placeholder?: string;
-  options?: Array<{ value: string; label: string }>;
+  options?: Array<{ value: string; label: string; }>;
   error?: string;
 }
 
@@ -44,54 +60,183 @@ const emit = defineEmits<{
   'update:modelValue': [val: Record<string, unknown>];
 }>();
 
-// ─── 约定式自动发现 ───────────────────────────────────────────────
 const route = useRoute();
+const router = useRouter();
+
 const registry = inject(
   PageSectionRegistryKey,
-  {} as Record<string, Record<string, () => Promise<{ default: unknown }>>>,
+  {} as Record<string, Record<string, () => Promise<{ default: unknown; }>>>,
 );
 const pageOverrides = registry[route.path] ?? {};
 
 for (const [name, loader] of Object.entries(pageOverrides)) {
   const key = SectionKeyMap[name];
   if (key) {
-    provide(key, defineAsyncComponent(loader as () => Promise<{ default: Component }>));
+    provide(key, defineAsyncComponent(loader as () => Promise<{ default: Component; }>));
   }
 }
 
-// ─── inject 回退到框架默认 ────────────────────────────────────────
 const PageHeaderComp = inject(FormPageHeaderKey, DefaultFormPageHeader);
 const FormContentComp = inject(FormContentKey, DefaultFormContent);
 const FormActionsComp = inject(FormActionsKey, DefaultFormActions);
+
+
+
+const apiPrefix = computed(() => routeToApiPrefix(route.path));
+const isEdit = computed(() => !!(route.query.id || route.query.id?.toString() === '0'));
+
+// 未传入 fields 时启动自动模式
+const auto = computed(() => !!!props.fields);
+
+const pageMeta = ref<{ addForm: BackendField[]; editForm: BackendField[]; } | null>(null);
+const internalModelValue = ref<Record<string, unknown>>({});
+const internalFields = ref<FormField[]>([]);
+const isSubmitting = ref(false);
+
+const TYPE_TO_FORM_TYPE: Record<string, FormField['type']> = {
+  String: 'text',
+  Int32: 'text',
+  Int64: 'text',
+  Decimal: 'text',
+  Double: 'text',
+  Boolean: 'select',
+  DateTime: 'text',
+};
+
+function backendFieldsToFormFields(fields: BackendField[]): FormField[] {
+  return fields
+    .filter((f) => !f.primaryKey && !f.readOnly)
+    .map((f) => {
+      const type = TYPE_TO_FORM_TYPE[f.typeName] ?? 'text';
+      const item: FormField = { key: f.name, label: f.displayName || f.name, type };
+      if (!f.nullable && !f.primaryKey) {
+        item.required = true;
+      }
+      if (f.length && f.length > 50 && type === 'text') {
+        item.type = 'textarea';
+      }
+      if (f.description) {
+        item.placeholder = f.description;
+      }
+      if (f.typeName === 'Boolean') {
+        item.type = 'select';
+        item.options = [
+          { value: 'true', label: '是' },
+          { value: 'false', label: '否' },
+        ];
+      }
+      return item;
+    });
+}
+
+async function fetchPageMeta() {
+  if (!auto.value) return;
+  try {
+    console.log('[DefaultForm] GetPage:', apiPrefix.value + '/GetPage');
+    const res = await request({ url: apiPrefix.value + '/GetPage', method: 'get' });
+    const meta = res as any;
+    pageMeta.value = meta;
+    const sourceFields = isEdit.value ? meta.editForm : meta.addForm;
+    if (sourceFields && sourceFields.length > 0) {
+      internalFields.value = backendFieldsToFormFields(sourceFields);
+    } else {
+      internalFields.value = backendFieldsToFormFields(meta.addForm ?? meta.editForm ?? []);
+    }
+  } catch (err) {
+    console.error('[DefaultForm] GetPage failed:', err);
+  }
+}
+
+async function fetchDetail() {
+  if (!auto.value || !isEdit.value) return;
+  const id = route.query.id;
+  try {
+    const url = apiPrefix.value + '/' + id;
+    console.log('[DefaultForm] Detail:', url);
+    const res: any = await request({ url, method: 'get' });
+    if (res && res.data) {
+      internalModelValue.value = { ...res.data };
+    } else if (res && typeof res === 'object') {
+      internalModelValue.value = { ...res };
+    } else {
+      internalModelValue.value = {};
+    }
+  } catch (err) {
+    console.error('[DefaultForm] Detail failed:', err);
+  }
+}
+
+async function handleSubmit() {
+  if (auto.value) {
+    if (isSubmitting.value) return;
+    isSubmitting.value = true;
+    try {
+      const model = internalModelValue.value;
+      if (isEdit.value) {
+        await request({ url: apiPrefix.value, method: 'put', data: model });
+        ElMessage.success('更新成功');
+      } else {
+        await request({ url: apiPrefix.value, method: 'post', data: model });
+        ElMessage.success('新增成功');
+      }
+      router.back();
+    } catch (err: any) {
+      const msg = err?.response?.data?.message || err?.message || '操作失败';
+      ElMessage.error(msg);
+    } finally {
+      isSubmitting.value = false;
+    }
+  } else {
+    emit('submit');
+  }
+}
+
+function handleCancel() {
+  if (auto.value) {
+    router.back();
+  } else {
+    emit('cancel');
+  }
+}
+
+function handleContinue() {
+  if (!auto.value) {
+    emit('continue');
+  }
+}
+
+function handleModelUpdate(val: Record<string, unknown>) {
+  if (auto.value) {
+    internalModelValue.value = val;
+  } else {
+    emit('update:modelValue', val);
+  }
+}
+
+const renderFields = computed(() => (auto.value ? internalFields.value : (props.fields ?? [])));
+const renderModelValue = computed(() => (auto.value ? internalModelValue.value : (props.modelValue ?? {})));
+
+onMounted(async () => {
+  if (auto.value) {
+    await fetchPageMeta();
+    await fetchDetail();
+  }
+});
 </script>
 
 <template>
   <div class="form-page">
-    <!-- 页头 -->
     <slot name="header">
       <component :is="PageHeaderComp" :title="title" :subtitle="subtitle" />
     </slot>
-
     <div class="fp-body">
-      <!-- 表单内容 -->
       <slot name="form">
-        <component
-          :is="FormContentComp"
-          :fields="fields"
-          :model-value="modelValue"
-          @update:model-value="emit('update:modelValue', $event)"
-        />
+        <component :is="FormContentComp" :fields="renderFields" :model-value="renderModelValue"
+          @update:model-value="handleModelUpdate" />
       </slot>
-
-      <!-- 操作区 -->
       <slot name="actions">
-        <component
-          :is="FormActionsComp"
-          :show-continue="showContinue"
-          @submit="emit('submit')"
-          @continue="emit('continue')"
-          @cancel="emit('cancel')"
-        />
+        <component :is="FormActionsComp" :show-continue="showContinue && !auto" @submit="handleSubmit"
+          @continue="handleContinue" @cancel="handleCancel" />
       </slot>
     </div>
   </div>
Modified +393 -41
diff --git a/core/views/index.vue b/core/views/index.vue
index d0549de..62b36c6 100644
--- a/core/views/index.vue
+++ b/core/views/index.vue
@@ -1,7 +1,18 @@
 <script setup lang="ts">
-import { inject, provide, defineAsyncComponent } from 'vue';
+import {
+  inject,
+  provide,
+  defineAsyncComponent,
+  ref,
+  reactive,
+  computed,
+  onMounted,
+  watch,
+} from 'vue';
 import type { Component } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import request from 'cube-front/core/utils/request';
 import {
   ListPageHeaderKey,
   ListSearchBarKey,
@@ -19,6 +30,65 @@ import DefaultListToolbar from 'cube-front/core/views/components/ListToolbar.vue
 import DefaultListTableContent from 'cube-front/core/views/components/ListTableContent.vue';
 import DefaultListPagination from 'cube-front/core/views/components/ListPagination.vue';
 import DefaultListPageFooter from 'cube-front/core/views/components/ListPageFooter.vue';
+import FormPage from 'cube-front/core/views/form.vue';
+import { routeToApiPrefix, getValueByKey } from 'cube-front/core/utils/url';
+
+interface FormField {
+  key: string;
+  label: string;
+  type: 'text' | 'email' | 'tel' | 'select' | 'textarea' | 'radio';
+  required?: boolean;
+  fullWidth?: boolean;
+  placeholder?: string;
+  options?: Array<{ value: string; label: string }>;
+}
+
+const TYPE_TO_SEARCH_TYPE: Record<string, 'text' | 'select'> = {
+  String: 'text',
+  Int32: 'text',
+  Int64: 'text',
+  Decimal: 'text',
+  Double: 'text',
+  Boolean: 'select',
+};
+
+interface BackendField {
+  name: string;
+  displayName: string;
+  description?: string;
+  typeName: string;
+  itemType?: string;
+  length?: number;
+  nullable?: boolean;
+  primaryKey?: boolean;
+  readOnly?: boolean;
+  mapField?: string;
+}
+
+interface BackendSearchField extends BackendField {
+  multiple?: boolean;
+}
+
+interface PageMeta {
+  setting: {
+    navView?: string;
+    enableNavbar?: boolean;
+    enableToolbar?: boolean;
+    enableAdd?: boolean;
+    enableKey?: boolean;
+    enableSelect?: boolean;
+    enableFooter?: boolean;
+    isReadOnly?: boolean;
+    enableTableDoubleClick?: boolean;
+    orderByKey?: boolean;
+    doubleDelete?: boolean;
+  };
+  list: BackendField[];
+  addForm: BackendField[];
+  editForm: BackendField[];
+  detail: BackendField[];
+  search: BackendSearchField[];
+}
 
 interface Column {
   key: string;
@@ -28,11 +98,11 @@ interface Column {
   mono?: boolean;
 }
 
-interface SearchField {
+interface SearchFieldItem {
   key: string;
   label: string;
   type: 'text' | 'select';
-  options?: Array<{ value: string; label: string }>;
+  options?: Array<{ value: string | number; label: string }>;
 }
 
 interface Props {
@@ -44,7 +114,7 @@ interface Props {
   total?: number;
   currentPage?: number;
   pageSize?: number;
-  searchFields?: SearchField[];
+  searchFields?: SearchFieldItem[];
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -65,8 +135,9 @@ const emit = defineEmits<{
   'update:pageSize': [size: number];
 }>();
 
-// ─── 约定式自动发现:读取当前路由对应的覆盖组件 ──────────────────
 const route = useRoute();
+const router = useRouter();
+
 const registry = inject(
   PageSectionRegistryKey,
   {} as Record<string, Record<string, () => Promise<{ default: unknown }>>>,
@@ -80,66 +151,347 @@ for (const [name, loader] of Object.entries(pageOverrides)) {
   }
 }
 
-// ─── inject 回退到框架默认 ────────────────────────────────────────
 const PageHeaderComp = inject(ListPageHeaderKey, DefaultListPageHeader);
 const SearchBarComp = inject(ListSearchBarKey, DefaultListSearchBar);
 const ToolbarComp = inject(ListToolbarKey, DefaultListToolbar);
 const TableContentComp = inject(ListTableContentKey, DefaultListTableContent);
 const PaginationComp = inject(ListPaginationKey, DefaultListPagination);
 const PageFooterComp = inject(ListPageFooterKey, DefaultListPageFooter);
+
+const apiPrefix = computed(() => routeToApiPrefix(route.path));
+
+// 弹窗表单相关
+const dialogVisible = ref(false);
+const dialogTitle = ref('新增');
+const dialogMode = ref<'add' | 'edit'>('add');
+const dialogFormData = ref<Record<string, unknown>>({});
+const dialogFields = ref<FormField[]>([]);
+const isSubmitting = ref(false);
+
+const TYPE_TO_FORM_TYPE: Record<string, FormField['type']> = {
+  String: 'text',
+  Int32: 'text',
+  Int64: 'text',
+  Decimal: 'text',
+  Double: 'text',
+  Boolean: 'select',
+  DateTime: 'text',
+};
+
+function backendFieldsToFormFields(fields: BackendField[]): FormField[] {
+  return fields
+    .filter((f) => !f.primaryKey && !f.readOnly)
+    .map((f) => {
+      const type = TYPE_TO_FORM_TYPE[f.typeName] ?? 'text';
+      const item: FormField = { key: f.name, label: f.displayName || f.name, type };
+      if (!f.nullable && !f.primaryKey) {
+        item.required = true;
+      }
+      if (f.length && f.length > 50 && type === 'text') {
+        item.type = 'textarea';
+      }
+      if (f.description) {
+        item.placeholder = f.description;
+      }
+      if (f.typeName === 'Boolean') {
+        item.type = 'select';
+        item.options = [
+          { value: 'true', label: '是' },
+          { value: 'false', label: '否' },
+        ];
+      }
+      return item;
+    });
+}
+
+function openDialog(mode: 'add' | 'edit', row?: Record<string, unknown>) {
+  dialogMode.value = mode;
+  dialogTitle.value = mode === 'add' ? '新增' : '编辑';
+  dialogFormData.value = mode === 'edit' && row ? { ...row } : {};
+  if (pageMeta.value) {
+    const sourceFields = mode === 'add' ? pageMeta.value.addForm : pageMeta.value.editForm;
+    dialogFields.value = backendFieldsToFormFields(sourceFields || pageMeta.value.addForm || []);
+  }
+  dialogVisible.value = true;
+}
+
+function closeDialog() {
+  dialogVisible.value = false;
+}
+
+async function submitDialog() {
+  if (isSubmitting.value) return;
+  isSubmitting.value = true;
+  try {
+    const url = apiPrefix.value;
+    const data = { ...dialogFormData.value };
+    if (dialogMode.value === 'edit') {
+      await request({ url, method: 'put', data });
+      ElMessage.success('更新成功');
+    } else {
+      await request({ url, method: 'post', data });
+      ElMessage.success('新增成功');
+    }
+    closeDialog();
+    await fetchList();
+  } catch (err: any) {
+    const msg = err?.response?.data?.message || err?.message || '操作失败';
+    ElMessage.error(msg);
+  } finally {
+    isSubmitting.value = false;
+  }
+}
+
+async function handleDeleteRow(row: Record<string, unknown>) {
+  try {
+    await ElMessageBox.confirm('确定要删除这条数据吗?', '确认删除', {
+      confirmButtonText: '删除',
+      cancelButtonText: '取消',
+      type: 'warning',
+    });
+    const id = getValueByKey(row, 'id') ?? getValueByKey(row, 'ID');
+    await request({ url: `${apiPrefix.value}/${id}`, method: 'delete' });
+    ElMessage.success('删除成功');
+    await fetchList();
+  } catch (err: any) {
+    if (err !== 'cancel') {
+      const msg = err?.response?.data?.message || err?.message || '删除失败';
+      ElMessage.error(msg);
+    }
+  }
+}
+
+function handleDialogUpdate(val: Record<string, unknown>) {
+  dialogFormData.value = val;
+}
+const auto = computed(() => !props.columns);
+
+const internalLoading = ref(false);
+const internalList = ref<Record<string, unknown>[]>([]);
+const internalTotal = ref(0);
+const internalPage = ref(1);
+const internalPageSize = ref(20);
+const pageMeta = ref<PageMeta | null>(null);
+const filterData = reactive<Record<string, unknown>>({});
+
+const computedSearchFields = computed<SearchFieldItem[]>(() => {
+  if (props.searchFields) return props.searchFields;
+  if (!pageMeta.value) return [];
+  return pageMeta.value.search
+    .filter((f) => !f.primaryKey)
+    .map((f) => {
+      const type = TYPE_TO_SEARCH_TYPE[f.typeName] ?? 'text';
+      const item: SearchFieldItem = { key: f.name, label: f.displayName, type };
+      if (f.typeName === 'Boolean') {
+        item.options = [
+          { value: 'true', label: '是' },
+          { value: 'false', label: '否' },
+        ];
+      }
+      return item;
+    });
+});
+
+const computedColumns = computed<Column[]>(() => {
+  if (props.columns) return props.columns;
+  if (!pageMeta.value) return [];
+  return pageMeta.value.list
+    .filter((f) => !f.name.endsWith('ID') || f.primaryKey)
+    .map((f) => {
+      const col: Column = { key: f.name, label: f.displayName || f.name };
+      if (f.typeName === 'Int32' || f.typeName === 'Int64') {
+        col.align = 'right';
+      }
+      if (f.primaryKey) {
+        col.mono = true;
+        col.width = '80px';
+      }
+      if (f.typeName === 'String' && (f.itemType === 'image' || (f.itemType && f.itemType.startsWith('file')))) {
+        col.width = '100px';
+      }
+      return col;
+    });
+});
+
+async function fetchPageMeta() {
+  if (!auto.value) return;
+  try {
+    const url =  apiPrefix.value + '/GetPage';
+    console.log('[DefaultList] GetPage:', url);
+    const res = await request({ url, method: 'get' });
+    pageMeta.value = res as unknown as PageMeta;
+  } catch (err) {
+    console.error('[DefaultList] GetPage failed:', err);
+  }
+}
+
+async function fetchList() {
+  if (!auto.value) return;
+  internalLoading.value = true;
+  try {
+    const params: Record<string, unknown> = { pageIndex: internalPage.value, pageSize: internalPageSize.value };
+    for (const [k, v] of Object.entries(filterData)) {
+      if (v !== '' && v !== null && v !== undefined) {
+        params[k] = v;
+      }
+    }
+    const url =  apiPrefix.value;
+    console.log('[DefaultList] Index:', url, params);
+    const res: any = await request({ url, method: 'get', params });
+    if (res && res.data && res.page) {
+      internalList.value = res.data;
+      internalTotal.value = res.page.totalCount ?? res.page.total ?? 0;
+    } else if (Array.isArray(res)) {
+      internalList.value = res;
+      internalTotal.value = res.length;
+    } else {
+      internalList.value = (res && res.data) ? res.data : (res ?? []);
+      internalTotal.value = internalList.value.length;
+    }
+  } catch (err) {
+    console.error('[DefaultList] Index failed:', err);
+  } finally {
+    internalLoading.value = false;
+  }
+}
+
+function handleSearch(params: Record<string, string>) {
+  if (auto.value) {
+    Object.assign(filterData, params);
+    internalPage.value = 1;
+    fetchList();
+  } else {
+    emit('search', params);
+  }
+}
+
+function handleReset() {
+  if (auto.value) {
+    for (const key in filterData) {
+      delete filterData[key];
+    }
+    internalPage.value = 1;
+    fetchList();
+  } else {
+    emit('reset');
+  }
+}
+
+function handleNew() {
+  if (auto.value) {
+    openDialog('add');
+  } else {
+    emit('new');
+  }
+}
+
+function handleEditRow(row: Record<string, unknown>) {
+  if (auto.value) {
+    openDialog('edit', row);
+  }
+}
+
+function handleDeleteRowTable(row: Record<string, unknown>) {
+  handleDeleteRow(row);
+}
+
+function handleDelete() {
+  if (auto.value) {
+    ElMessage.info('请使用表格行操作进行删除');
+  } else {
+    emit('delete');
+  }
+}
+
+async function handleRefresh() {
+  if (auto.value) {
+    await fetchList();
+  } else {
+    emit('refresh');
+  }
+}
+
+function handleExport() {
+  emit('export');
+}
+
+function handlePageChange(page: number) {
+  if (auto.value) {
+    internalPage.value = page;
+    fetchList();
+  } else {
+    emit('update:currentPage', page);
+  }
+}
+
+function handlePageSizeChange(size: number) {
+  if (auto.value) {
+    internalPageSize.value = size;
+    internalPage.value = 1;
+    fetchList();
+  } else {
+    emit('update:pageSize', size);
+  }
+}
+
+const renderData = computed(() => (auto.value ? internalList.value : (props.data ?? [])));
+const renderTotal = computed(() => (auto.value ? internalTotal.value : props.total));
+const renderPage = computed(() => (auto.value ? internalPage.value : props.currentPage));
+const renderPageSize = computed(() => (auto.value ? internalPageSize.value : props.pageSize));
+const renderLoading = computed(() => (auto.value ? internalLoading.value : props.loading));
+
+watch(() => route.path, () => {
+  if (auto.value) {
+    internalPage.value = 1;
+    pageMeta.value = null;
+    internalList.value = [];
+    fetchPageMeta();
+    fetchList();
+  }
+});
+
+onMounted(async () => {
+  if (auto.value) {
+    await fetchPageMeta();
+    await fetchList();
+  }
+});
 </script>
 
 <template>
   <div class="list-page">
-    <!-- 页头(在 body 外,贴顶) -->
     <slot name="header">
       <component :is="PageHeaderComp" :title="title" :subtitle="subtitle" />
     </slot>
-
     <div class="lp-body">
-      <!-- 搜索栏 -->
       <slot name="search">
-        <component
-          :is="SearchBarComp"
-          :fields="searchFields"
-          @search="emit('search', $event)"
-          @reset="emit('reset')"
-        />
+        <component :is="SearchBarComp" :fields="searchFields ?? computedSearchFields" @search="handleSearch" @reset="handleReset" />
       </slot>
-
-      <!-- 工具栏 -->
       <slot name="toolbar">
-        <component
-          :is="ToolbarComp"
-          @new="emit('new')"
-          @delete="emit('delete')"
-          @export="emit('export')"
-          @refresh="emit('refresh')"
-        />
+        <component :is="ToolbarComp" @new="handleNew" @delete="handleDelete" @export="handleExport" @refresh="handleRefresh" />
       </slot>
-
-      <!-- 表格 -->
       <slot name="table">
-        <component :is="TableContentComp" :columns="columns" :data="data" :loading="loading" />
+        <component :is="TableContentComp" :columns="columns ?? computedColumns" :data="renderData" :loading="renderLoading" @edit="handleEditRow" @delete="handleDeleteRowTable" />
       </slot>
-
-      <!-- 分页 -->
       <slot name="pagination">
-        <component
-          :is="PaginationComp"
-          :total="total"
-          :current-page="currentPage"
-          :page-size="pageSize"
-          @update:current-page="emit('update:currentPage', $event)"
-          @update:page-size="emit('update:pageSize', $event)"
-        />
+        <component :is="PaginationComp" :total="renderTotal" :current-page="renderPage" :page-size="renderPageSize" @update:current-page="handlePageChange" @update:page-size="handlePageSizeChange" />
       </slot>
-
-      <!-- 底部 -->
       <slot name="footer">
         <component :is="PageFooterComp" />
       </slot>
     </div>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px" destroy-on-close>
+      <FormPage
+        :fields="dialogFields"
+        :model-value="dialogFormData"
+        :show-continue="false"
+        @update:model-value="handleDialogUpdate"
+        @submit="submitDialog"
+        @cancel="closeDialog"
+      />
+    </el-dialog>
   </div>
 </template>
 
@@ -169,4 +521,4 @@ const PageFooterComp = inject(ListPageFooterKey, DefaultListPageFooter);
     border-radius: 3px;
   }
 }
-</style>
+</style>
\ No newline at end of file