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();
-}
-
-/**
* 根据路由路径解析对应的视图组件。
*
* 约定规则:
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) {
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);
}
}
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;
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) {
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;
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>
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