diff --git a/.gitignore b/.gitignore index 23784bdab..67acfe627 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ package-lock.json pnpm-lock.yaml .VSCodeCounter **/backend-mock/data - +.omx # local env files .env.local .env.*.local diff --git a/apps/web-antd/index.html b/apps/web-antd/index.html index 33d34a9e2..e60ec0cb6 100644 --- a/apps/web-antd/index.html +++ b/apps/web-antd/index.html @@ -12,7 +12,7 @@ content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0" /> - <%= VITE_APP_TITLE %> + %VITE_APP_TITLE% diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index c7e59a831..c1870de5e 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -106,6 +106,7 @@ onMounted(() => notifyStore.startListeningMessage()); function handleViewAll() { window.message.warning('暂未开放'); } + watch( () => ({ enable: preferences.app.watermark, diff --git a/apps/web-antd/src/main.ts b/apps/web-antd/src/main.ts index 5d728a02a..110acbd7c 100644 --- a/apps/web-antd/src/main.ts +++ b/apps/web-antd/src/main.ts @@ -1,7 +1,7 @@ import { initPreferences } from '@vben/preferences'; import { unmountGlobalLoading } from '@vben/utils'; -import { overridesPreferences } from './preferences'; +import { overridesPreferences, preferencesExtension } from './preferences'; /** * 应用初始化完成之后再进行页面加载渲染 @@ -15,6 +15,7 @@ async function initApplication() { // app偏好设置初始化 await initPreferences({ + extension: preferencesExtension, namespace, overrides: overridesPreferences, }); diff --git a/apps/web-antd/src/preferences.ts b/apps/web-antd/src/preferences.ts index 6cab7024f..9d63c9d62 100644 --- a/apps/web-antd/src/preferences.ts +++ b/apps/web-antd/src/preferences.ts @@ -1,4 +1,14 @@ -import { defineOverridesPreferences } from '@vben/preferences'; +import { + defineOverridesPreferences, + definePreferencesExtension, +} from '@vben/preferences'; + +interface WebAntdPreferencesExtension { + defaultTableSize: number; + enableFormFullscreen: boolean; + reportTitle: string; + tenantMode: 'multi' | 'single'; +} /** * @description 项目配置文件 @@ -107,3 +117,52 @@ export const overridesPreferences = defineOverridesPreferences({ // source: '', // }, }); + +export const preferencesExtension = + definePreferencesExtension({ + tabLabel: 'preferences.antd.tabLabel', + title: 'preferences.antd.title', + fields: [ + { + component: 'switch', + defaultValue: true, + key: 'enableFormFullscreen', + label: 'preferences.antd.fields.enableFormFullscreen.label', + tip: 'preferences.antd.fields.enableFormFullscreen.tip', + }, + { + component: 'select', + defaultValue: 'single', + key: 'tenantMode', + label: 'preferences.antd.fields.tenantMode.label', + options: [ + { + label: 'preferences.antd.fields.tenantMode.options.single.label', + value: 'single', + }, + { + label: 'preferences.antd.fields.tenantMode.options.multi.label', + value: 'multi', + }, + ], + }, + { + component: 'number', + componentProps: { + max: 200, + min: 10, + step: 10, + }, + defaultValue: 20, + key: 'defaultTableSize', + label: 'preferences.antd.fields.defaultTableSize.label', + }, + { + component: 'input', + defaultValue: '', + key: 'reportTitle', + label: 'preferences.antd.fields.reportTitle.label', + placeholder: 'preferences.antd.fields.reportTitle.placeholder', + }, + ], + }); diff --git a/apps/web-antd/src/views/演示使用自行删除/form/collapsible.vue b/apps/web-antd/src/views/演示使用自行删除/form/collapsible.vue new file mode 100644 index 000000000..ba65ba30d --- /dev/null +++ b/apps/web-antd/src/views/演示使用自行删除/form/collapsible.vue @@ -0,0 +1,315 @@ + + + diff --git a/apps/web-antd/src/views/演示使用自行删除/form/value-format.vue b/apps/web-antd/src/views/演示使用自行删除/form/value-format.vue new file mode 100644 index 000000000..bf1449f88 --- /dev/null +++ b/apps/web-antd/src/views/演示使用自行删除/form/value-format.vue @@ -0,0 +1,161 @@ + + + diff --git a/docs/src/demos/vben-form/value-format/index.vue b/docs/src/demos/vben-form/value-format/index.vue new file mode 100644 index 000000000..0bd870d99 --- /dev/null +++ b/docs/src/demos/vben-form/value-format/index.vue @@ -0,0 +1,138 @@ + + + diff --git a/docs/src/en/components/common-ui/vben-form.md b/docs/src/en/components/common-ui/vben-form.md index d23e869bc..58cd7a4d4 100644 --- a/docs/src/en/components/common-ui/vben-form.md +++ b/docs/src/en/components/common-ui/vben-form.md @@ -191,12 +191,23 @@ Create the form through `useVbenForm`: +## Value Formatting + +Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape. + +- return a value to write back to the current field +- call `setValue(key, nextValue)` to write derived fields +- return `undefined` to keep the original field removed after decomposition + + + ## Key API Notes - `useVbenForm` returns `[Form, formApi]` - `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions - `handleValuesChange(values, fieldsChanged)` includes the second parameter in newer versions - `fieldMappingTime` and `scrollToFirstError` are part of the current form props +- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads ## Reference diff --git a/internal/lint-configs/oxlint-config/src/configs/tailwindcss.ts b/internal/lint-configs/oxlint-config/src/configs/tailwindcss.ts index 5e7bff5ab..d138d9e85 100644 --- a/internal/lint-configs/oxlint-config/src/configs/tailwindcss.ts +++ b/internal/lint-configs/oxlint-config/src/configs/tailwindcss.ts @@ -1,5 +1,7 @@ import type { OxlintConfig } from 'oxlint'; +import { fileURLToPath } from 'node:url'; + import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss'; import { getDefaultSelectors } from 'eslint-plugin-better-tailwindcss/defaults'; import { SelectorKind } from 'eslint-plugin-better-tailwindcss/types'; @@ -13,8 +15,12 @@ const selectors = [ }, ]; +const entryPoint = fileURLToPath( + new URL('../../../../tailwind-config/src/theme.css', import.meta.url), +); + const settings = { - entryPoint: 'internal/tailwind-config/src/theme.css', + entryPoint, selectors, }; diff --git a/internal/node-utils/tsconfig.build.json b/internal/node-utils/tsconfig.build.json index b62e0ab6b..714b1f8e4 100644 --- a/internal/node-utils/tsconfig.build.json +++ b/internal/node-utils/tsconfig.build.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", "compilerOptions": { + "rootDir": "./src", "noEmit": false }, "exclude": ["node_modules", "src/__tests__"] diff --git a/internal/tailwind-config/src/theme.css b/internal/tailwind-config/src/theme.css index c212dc66e..076c1cfb2 100644 --- a/internal/tailwind-config/src/theme.css +++ b/internal/tailwind-config/src/theme.css @@ -380,6 +380,13 @@ @apply hidden; } } + + /* Tailwind v4 Preflight 不再为 button 默认设置 pointer;见官方升级说明: + * https://tailwindcss.com/docs/upgrade-guide#buttons-use-the-default-cursor */ + button:not(:disabled), + [role='button']:not(:disabled) { + @apply cursor-pointer; + } } /* Custom utilities (v4 @utility syntax) */ @@ -396,33 +403,36 @@ justify-content: center; } -/* Component styles (complex selectors, not convertible to @utility) */ -.outline-box { - @apply outline-border relative cursor-pointer rounded-md p-1 outline-1; -} +/* Tailwind v4 的 utilities 在 @layer 内;组件样式若留在 layer 外,会按层叠规则压过 py-4 等工具类。 + * 见:https://tailwindcss.com/docs/adding-custom-styles#using-css-and-layering */ +@layer components { + .outline-box { + @apply outline-border relative cursor-pointer rounded-md p-1 outline-1; + } -.outline-box::after { - @apply absolute top-1/2 left-1/2 z-20 h-0 w-px rounded-sm opacity-0 outline-2 outline-transparent transition-all duration-300 content-[""]; -} + .outline-box::after { + @apply absolute top-1/2 left-1/2 z-20 h-0 w-px rounded-sm opacity-0 outline-2 outline-transparent transition-all duration-300 content-[""]; + } -.outline-box.outline-box-active { - @apply outline-primary outline-2; -} + .outline-box.outline-box-active { + @apply outline-primary outline-2; + } -.outline-box.outline-box-active::after { - display: none; -} + .outline-box.outline-box-active::after { + display: none; + } -.outline-box:not(.outline-box-active):hover::after { - @apply outline-primary top-0 left-0 h-full w-full p-1 opacity-100; -} + .outline-box:not(.outline-box-active):hover::after { + @apply outline-primary top-0 left-0 h-full w-full p-1 opacity-100; + } -.vben-link { - @apply text-primary hover:text-primary-hover active:text-primary-active cursor-pointer; -} + .vben-link { + @apply text-primary hover:text-primary-hover active:text-primary-active cursor-pointer; + } -.card-box { - @apply bg-card text-card-foreground border-border rounded-xl border; + .card-box { + @apply bg-card text-card-foreground border-border rounded-xl border; + } } /* Enter animations (converted from enterAnimationPlugin) */ diff --git a/internal/tailwind-config/tsconfig.json b/internal/tailwind-config/tsconfig.json new file mode 100644 index 000000000..ce1a891fb --- /dev/null +++ b/internal/tailwind-config/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vben/tsconfig/web.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/internal/vite-config/package.json b/internal/vite-config/package.json index ca2049087..a3c48e8e8 100644 --- a/internal/vite-config/package.json +++ b/internal/vite-config/package.json @@ -55,7 +55,6 @@ "unplugin-dts": "catalog:", "vite": "catalog:", "vite-plugin-compression": "catalog:", - "vite-plugin-html": "catalog:", "vite-plugin-lazy-import": "catalog:" } } diff --git a/internal/vite-config/src/plugins/html.ts b/internal/vite-config/src/plugins/html.ts new file mode 100644 index 000000000..42eb8a6a4 --- /dev/null +++ b/internal/vite-config/src/plugins/html.ts @@ -0,0 +1,35 @@ +import type { Options as HtmlMinifierOptions } from 'html-minifier-terser'; +import type { PluginOption } from 'vite'; + +import { minify } from 'html-minifier-terser'; + +const HTML_MINIFY_OPTIONS = { + collapseWhitespace: true, + minifyCSS: true, + minifyJS: true, + removeComments: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + useShortDoctype: true, +} as const; + +function viteHtmlPlugin(options: HtmlMinifierOptions = {}): PluginOption { + return { + name: 'vben-native-html', + transformIndexHtml: { + order: 'post', + async handler(html, ctx) { + if (!ctx.bundle) { + return html; + } + return await minify(html, { + ...HTML_MINIFY_OPTIONS, + ...options, + }); + }, + }, + }; +} + +export { viteHtmlPlugin }; diff --git a/internal/vite-config/src/plugins/index.ts b/internal/vite-config/src/plugins/index.ts index 9d3ba3f1c..37cb61fbe 100644 --- a/internal/vite-config/src/plugins/index.ts +++ b/internal/vite-config/src/plugins/index.ts @@ -14,12 +14,12 @@ import viteVueJsx from '@vitejs/plugin-vue-jsx'; import { visualizer as viteVisualizerPlugin } from 'rollup-plugin-visualizer'; import viteDtsPlugin from 'unplugin-dts/vite'; import viteCompressPlugin from 'vite-plugin-compression'; -import { createHtmlPlugin as viteHtmlPlugin } from 'vite-plugin-html'; import { VitePWA } from 'vite-plugin-pwa'; import viteVueDevTools from 'vite-plugin-vue-devtools'; import { viteArchiverPlugin } from './archiver'; import { viteExtraAppConfigPlugin } from './extra-app-config'; +import { viteHtmlPlugin } from './html'; import { viteImportMapPlugin } from './importmap'; import { viteInjectAppLoadingPlugin } from './inject-app-loading'; import { viteMetadataPlugin } from './inject-metadata'; @@ -199,7 +199,7 @@ async function loadApplicationPlugins( }, { condition: !!html, - plugins: () => [viteHtmlPlugin({ minify: true })], + plugins: () => [viteHtmlPlugin(typeof html === 'object' ? html : {})], }, { condition: isBuild && importmap, diff --git a/internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html b/internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html index a8a51f082..cf5d1e900 100644 --- a/internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html +++ b/internal/vite-config/src/plugins/inject-app-loading/default-loading-antd.html @@ -103,5 +103,5 @@
-
<%= VITE_APP_TITLE %>
+
%VITE_APP_TITLE%
diff --git a/internal/vite-config/src/plugins/inject-app-loading/default-loading.html b/internal/vite-config/src/plugins/inject-app-loading/default-loading.html index 3c7e659f1..bcff10f41 100644 --- a/internal/vite-config/src/plugins/inject-app-loading/default-loading.html +++ b/internal/vite-config/src/plugins/inject-app-loading/default-loading.html @@ -22,8 +22,8 @@ } .loading.hidden { - pointer-events: none; visibility: hidden; + pointer-events: none; opacity: 0; transition: all 0.8s ease-out; } @@ -109,5 +109,5 @@
-
<%= VITE_APP_TITLE %>
+
%VITE_APP_TITLE%
diff --git a/internal/vite-config/src/typing.ts b/internal/vite-config/src/typing.ts index 376b47b8a..354bea25f 100644 --- a/internal/vite-config/src/typing.ts +++ b/internal/vite-config/src/typing.ts @@ -1,3 +1,4 @@ +import type { Options as HtmlMinifierOptions } from 'html-minifier-terser'; import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer'; import type { PluginOptions } from 'unplugin-dts'; import type { @@ -94,6 +95,12 @@ interface ArchiverPluginOptions { outputDir?: string; } +/** + * HTML 插件配置 + * @description 用于配置基于 transformIndexHtml 的 HTML 压缩行为 + */ +type HtmlPluginOptions = HtmlMinifierOptions; + /** * ImportMap 插件配置 * @description 用于配置模块的 CDN 导入 @@ -217,7 +224,7 @@ interface ApplicationPluginOptions extends CommonPluginOptions { * 是否开启 HTML 插件 * @default true */ - html?: boolean; + html?: boolean | HtmlPluginOptions; /** * 是否开启国际化 * @default false @@ -342,6 +349,7 @@ export type { DefineApplicationOptions, DefineConfig, DefineLibraryOptions, + HtmlPluginOptions, IImportMap, ImportmapPluginOptions, LibraryPluginOptions, diff --git a/package.json b/package.json index b7a212d5b..af6b31765 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "devDependencies": { "@changesets/changelog-github": "catalog:", "@changesets/cli": "catalog:", - "@playwright/test": "catalog:", "@tsdown/css": "catalog:", "@types/node": "catalog:", "@vben/commitlint-config": "workspace:*", @@ -73,7 +72,6 @@ "@vben/vsh": "workspace:*", "@vitejs/plugin-vue": "catalog:", "@vitejs/plugin-vue-jsx": "catalog:", - "@vue/test-utils": "catalog:", "cross-env": "catalog:", "cspell": "catalog:", "happy-dom": "catalog:", @@ -88,7 +86,6 @@ "tsdown": "catalog:", "turbo": "catalog:", "typescript": "catalog:", - "unplugin-vue": "catalog:", "vite": "catalog:", "vitest": "catalog:", "vue": "catalog:", diff --git a/packages/@core/base/icons/src/lucide.ts b/packages/@core/base/icons/src/lucide.ts index ce7e930e6..acaecd92e 100644 --- a/packages/@core/base/icons/src/lucide.ts +++ b/packages/@core/base/icons/src/lucide.ts @@ -16,6 +16,7 @@ export { ChevronDown, ChevronLeft, ChevronRight, + ChevronsDown, ChevronsLeft, ChevronsRight, Circle, @@ -25,6 +26,7 @@ export { CircleX, Copy, CornerDownLeft, + Download, Ellipsis, Eraser, Expand, diff --git a/packages/@core/preferences/__tests__/preferences.test.ts b/packages/@core/preferences/__tests__/preferences.test.ts index 37657d89f..84e9844e4 100644 --- a/packages/@core/preferences/__tests__/preferences.test.ts +++ b/packages/@core/preferences/__tests__/preferences.test.ts @@ -1,11 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { defaultPreferences } from '../src/config'; -import { PreferenceManager } from '../src/preferences'; import { isDarkTheme } from '../src/update-css-variables'; describe('preferences', () => { - let preferenceManager: PreferenceManager; + let PreferenceManager: typeof import('../src/preferences').PreferenceManager; + let preferenceManager: InstanceType< + typeof import('../src/preferences').PreferenceManager + >; // 模拟 window.matchMedia 方法 vi.stubGlobal( @@ -21,7 +23,36 @@ describe('preferences', () => { removeListener: vi.fn(), // Deprecated })), ); + + vi.stubGlobal('localStorage', { + clear: vi.fn(), + getItem: vi.fn(() => null), + key: vi.fn(() => null), + length: 0, + removeItem: vi.fn(), + setItem: vi.fn(), + }); + + vi.stubGlobal('sessionStorage', { + clear: vi.fn(), + getItem: vi.fn(() => null), + key: vi.fn(() => null), + length: 0, + removeItem: vi.fn(), + setItem: vi.fn(), + }); + + beforeAll(async () => { + ({ PreferenceManager } = await import('../src/preferences')); + }); + beforeEach(() => { + vi.mocked(localStorage.getItem).mockImplementation(() => null); + vi.mocked(localStorage.removeItem).mockReset(); + vi.mocked(localStorage.setItem).mockReset(); + vi.mocked(sessionStorage.getItem).mockImplementation(() => null); + vi.mocked(sessionStorage.removeItem).mockReset(); + vi.mocked(sessionStorage.setItem).mockReset(); preferenceManager = new PreferenceManager(); }); @@ -214,7 +245,10 @@ describe('preferences', () => { }, }; - await preferenceManager.initPreferences(overrides); + await preferenceManager.initPreferences({ + namespace: 'apply-updates', + overrides, + }); preferenceManager.updatePreferences({ theme: { mode: 'light' }, @@ -222,6 +256,265 @@ describe('preferences', () => { expect(preferenceManager.getPreferences().theme.mode).toBe('light'); }); + + it('initializes custom preferences extension with default values', async () => { + const extension = { + fields: [ + { + component: 'switch', + defaultValue: true, + key: 'enableWorkbench', + label: '启用工作台', + }, + { + component: 'select', + defaultValue: 'single', + key: 'tenantMode', + label: '租户模式', + options: [ + { label: '单租户', value: 'single' }, + { label: '多租户', value: 'multi' }, + ], + }, + ], + tabLabel: '扩展', + title: '业务偏好', + } as const; + + await preferenceManager.initPreferences({ + extension, + namespace: 'custom-defaults', + }); + + expect(preferenceManager.getPreferencesExtension()).toEqual(extension); + expect(preferenceManager.getCustomPreferences()).toEqual({ + enableWorkbench: true, + tenantMode: 'single', + }); + }); + + it('does not expose mutable custom preference baselines or extension schema', async () => { + const extension = { + fields: [ + { + component: 'number', + componentProps: { + max: 10, + min: 2, + step: 2, + }, + defaultValue: 4, + key: 'pageSize', + label: '分页大小', + }, + ], + tabLabel: '扩展', + title: '业务偏好', + } as const; + + await preferenceManager.initPreferences({ + extension, + namespace: 'custom-readonly', + }); + + const initialCustomPreferences = + preferenceManager.getInitialCustomPreferences<{ + pageSize: number; + }>() as { pageSize: number }; + const preferencesExtension = preferenceManager.getPreferencesExtension<{ + pageSize: number; + }>() as { + fields: Array<{ componentProps?: { max?: number }; label: string }>; + }; + const [firstField] = preferencesExtension.fields; + + initialCustomPreferences.pageSize = 8; + expect(firstField).toBeDefined(); + expect(firstField?.componentProps).toBeDefined(); + + if (!firstField || !firstField.componentProps) { + return; + } + + firstField.label = '已修改'; + firstField.componentProps.max = 20; + + expect(preferenceManager.getInitialCustomPreferences()).toEqual({ + pageSize: 4, + }); + expect(preferenceManager.getPreferencesExtension()).toEqual(extension); + }); + + it('updates and resets custom preferences correctly', async () => { + await preferenceManager.initPreferences({ + extension: { + fields: [ + { + component: 'number', + defaultValue: 20, + key: 'pageSize', + label: '分页大小', + }, + { + component: 'input', + defaultValue: '日报', + key: 'reportTitle', + label: '报表标题', + }, + ], + tabLabel: '扩展', + }, + namespace: 'custom-reset', + }); + + preferenceManager.updateCustomPreferences({ + pageSize: 50, + reportTitle: '月报', + }); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 50, + reportTitle: '月报', + }); + + preferenceManager.resetPreferences(); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 20, + reportTitle: '日报', + }); + }); + + it('ignores invalid custom preferences updates', async () => { + await preferenceManager.initPreferences({ + extension: { + fields: [ + { + component: 'switch', + defaultValue: true, + key: 'enableWorkbench', + label: '启用工作台', + }, + { + component: 'select', + defaultValue: 'single', + key: 'tenantMode', + label: '租户模式', + options: [ + { label: '单租户', value: 'single' }, + { label: '多租户', value: 'multi' }, + ], + }, + ], + tabLabel: '扩展', + }, + namespace: 'custom-invalid', + }); + + const originalCustomPreferences = preferenceManager.getCustomPreferences(); + + preferenceManager.updateCustomPreferences({ + enableWorkbench: 'true' as unknown as boolean, + tenantMode: 'unknown', + unknownField: 'value', + } as any); + + expect(preferenceManager.getCustomPreferences()).toEqual( + originalCustomPreferences, + ); + }); + + it('enforces custom number field min max and step constraints', async () => { + await preferenceManager.initPreferences({ + extension: { + fields: [ + { + component: 'number', + componentProps: { + max: 10, + min: 2, + step: 2, + }, + defaultValue: 4, + key: 'pageSize', + label: '分页大小', + }, + ], + tabLabel: '扩展', + }, + namespace: 'custom-number-constraints', + }); + + preferenceManager.updateCustomPreferences({ + pageSize: 8, + }); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 8, + }); + + preferenceManager.updateCustomPreferences({ + pageSize: 1, + }); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 8, + }); + + preferenceManager.updateCustomPreferences({ + pageSize: 12, + }); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 8, + }); + + preferenceManager.updateCustomPreferences({ + pageSize: 5, + }); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 8, + }); + }); + + it('filters cached custom number values that violate field constraints', async () => { + vi.mocked(localStorage.getItem).mockImplementation((key) => { + if (key.endsWith('cache-preferences-custom')) { + return JSON.stringify({ + value: { + pageSize: 5, + }, + }); + } + + return null; + }); + + await preferenceManager.initPreferences({ + extension: { + fields: [ + { + component: 'number', + componentProps: { + max: 10, + min: 2, + step: 2, + }, + defaultValue: 4, + key: 'pageSize', + label: '分页大小', + }, + ], + tabLabel: '扩展', + }, + namespace: 'custom-number-cache', + }); + + expect(preferenceManager.getCustomPreferences()).toEqual({ + pageSize: 4, + }); + }); }); describe('isDarkTheme', () => { diff --git a/packages/@core/preferences/src/index.ts b/packages/@core/preferences/src/index.ts index 15ec89639..d81823d17 100644 --- a/packages/@core/preferences/src/index.ts +++ b/packages/@core/preferences/src/index.ts @@ -4,7 +4,11 @@ import { preferencesManager } from './preferences'; export const { getPreferences, + getCustomPreferences, + getInitialCustomPreferences, + getPreferencesExtension, updatePreferences, + updateCustomPreferences, resetPreferences, clearCache, initPreferences, diff --git a/packages/@core/preferences/src/preferences.ts b/packages/@core/preferences/src/preferences.ts index 1dfd530c7..7f82bb094 100644 --- a/packages/@core/preferences/src/preferences.ts +++ b/packages/@core/preferences/src/preferences.ts @@ -1,6 +1,12 @@ import type { DeepPartial } from '@vben-core/typings'; -import type { InitialOptions, Preferences } from './types'; +import type { + CustomPreferencesField, + CustomPreferencesRecord, + InitialOptions, + Preferences, + PreferencesExtension, +} from './types'; import { markRaw, reactive, readonly, watch } from 'vue'; @@ -17,6 +23,7 @@ import { defaultPreferences } from './config'; import { updateCSSVariables } from './update-css-variables'; const STORAGE_KEYS = { + CUSTOM: 'preferences-custom', MAIN: 'preferences', LOCALE: 'preferences-locale', THEME: 'preferences-theme', @@ -24,7 +31,10 @@ const STORAGE_KEYS = { class PreferenceManager { private cache: StorageManager; - private debouncedSave: (preference: Preferences) => void; + private customPreferencesExtension: null | PreferencesExtension = null; + private customState = reactive({}); + private debouncedSave: () => void; + private initialCustomPreferences: CustomPreferencesRecord = {}; private initialPreferences: Preferences = defaultPreferences; private isInitialized = false; private state: Preferences; @@ -34,10 +44,7 @@ class PreferenceManager { this.state = reactive( this.loadFromCache() || { ...defaultPreferences }, ); - this.debouncedSave = useDebounceFn( - (preference) => this.saveToCache(preference), - 150, - ); + this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150); } /** @@ -47,6 +54,26 @@ class PreferenceManager { Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key)); }; + /** + * 获取扩展偏好设置 + */ + getCustomPreferences = < + TCustomPreferences extends object = CustomPreferencesRecord, + >() => { + return readonly(this.customState) as Readonly; + }; + + /** + * 获取初始化扩展偏好设置 + */ + getInitialCustomPreferences = < + TCustomPreferences extends object = CustomPreferencesRecord, + >() => { + return this.cloneValue( + this.initialCustomPreferences, + ) as Readonly; + }; + /** * 获取初始化偏好设置 */ @@ -61,13 +88,32 @@ class PreferenceManager { return readonly(this.state); }; + /** + * 获取扩展偏好设置配置 + */ + getPreferencesExtension = < + TCustomPreferences extends object = CustomPreferencesRecord, + >() => { + return this.customPreferencesExtension + ? (this.cloneValue(this.customPreferencesExtension) as Readonly< + PreferencesExtension + >) + : null; + }; + /** * 初始化偏好设置 * @param options - 初始化配置项 * @param options.namespace - 命名空间,用于隔离不同应用的配置 * @param options.overrides - 要覆盖的偏好设置 */ - initPreferences = async ({ namespace, overrides }: InitialOptions) => { + initPreferences = async < + TCustomPreferences extends object = CustomPreferencesRecord, + >({ + namespace, + overrides, + extension, + }: InitialOptions) => { // 防止重复初始化 if (this.isInitialized) { return; @@ -78,6 +124,10 @@ class PreferenceManager { // 合并初始偏好设置 this.initialPreferences = merge({}, overrides, defaultPreferences); + this.customPreferencesExtension = extension ?? null; + this.initialCustomPreferences = this.resolveCustomPreferencesDefaults( + this.customPreferencesExtension, + ); // 加载缓存的偏好设置并与初始配置合并 const cachedPreferences = this.loadFromCache() || {}; @@ -89,6 +139,14 @@ class PreferenceManager { // 更新偏好设置 this.updatePreferences(mergedPreference); + this.replaceCustomPreferences( + merge( + {}, + this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}), + this.initialCustomPreferences, + ), + ); + this.saveToCache(); // 设置监听器 this.setupWatcher(); @@ -105,14 +163,42 @@ class PreferenceManager { resetPreferences = () => { // 将状态重置为初始偏好设置 Object.assign(this.state, this.initialPreferences); + this.replaceCustomPreferences(this.initialCustomPreferences); // 保存偏好设置至缓存 - this.saveToCache(this.state); + this.saveToCache(); // 直接触发 UI 更新 this.handleUpdates(this.state); }; + /** + * 更新扩展偏好设置 + * @param updates - 要更新的扩展偏好设置 + */ + updateCustomPreferences = < + TCustomPreferences extends object = CustomPreferencesRecord, + >( + updates: DeepPartial, + ) => { + if (!this.customPreferencesExtension) { + return; + } + + const sanitizedUpdates = this.sanitizeCustomPreferences( + updates as DeepPartial, + ); + + if (Object.keys(sanitizedUpdates).length === 0) { + return; + } + + this.replaceCustomPreferences( + merge({}, sanitizedUpdates, markRaw(this.customState)), + ); + this.debouncedSave(); + }; + /** * 更新偏好设置 * @param updates - 要更新的偏好设置 @@ -126,9 +212,25 @@ class PreferenceManager { this.handleUpdates(updates); // 保存到缓存 - this.debouncedSave(this.state); + this.debouncedSave(); }; + private cloneValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => this.cloneValue(item)) as T; + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map( + ([key, nestedValue]) => [key, this.cloneValue(nestedValue)], + ), + ) as T; + } + + return value; + } + /** * 处理更新 * @param updates - 更新的偏好设置 @@ -158,6 +260,70 @@ class PreferenceManager { document.documentElement.dataset.platform = isMacOs() ? 'macOs' : 'window'; } + private isAlmostInteger(value: number, epsilon = Number.EPSILON * 10) { + return Math.abs(value - Math.round(value)) < epsilon; + } + + private isValidCustomPreferenceValue( + field: CustomPreferencesField, + value: unknown, + ) { + switch (field.component) { + case 'number': { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return false; + } + + const max = this.resolveNumericConstraint(field.componentProps?.max); + const min = this.resolveNumericConstraint(field.componentProps?.min); + const step = this.resolveNumericConstraint(field.componentProps?.step); + + if (min !== undefined && value < min) { + return false; + } + + if (max !== undefined && value > max) { + return false; + } + + if (step !== undefined) { + if (step <= 0) { + return false; + } + + const stepBase = min ?? 0; + const stepCount = (value - stepBase) / step; + + if (!this.isAlmostInteger(stepCount)) { + return false; + } + } + + return true; + } + case 'select': { + return ( + typeof value === 'string' && + field.options.some((option) => option.value === value) + ); + } + case 'switch': { + return typeof value === 'boolean'; + } + default: { + return typeof value === 'string'; + } + } + } + + /** + * 从缓存加载扩展偏好设置 + * @returns 缓存的扩展偏好设置,如果不存在则返回 null + */ + private loadCustomFromCache(): CustomPreferencesRecord | null { + return this.cache.getItem(STORAGE_KEYS.CUSTOM); + } + /** * 从缓存加载偏好设置 * @returns 缓存的偏好设置,如果不存在则返回 null @@ -166,14 +332,72 @@ class PreferenceManager { return this.cache.getItem(STORAGE_KEYS.MAIN); } + private replaceCustomPreferences(preferences: CustomPreferencesRecord) { + Object.keys(this.customState).forEach((key) => { + Reflect.deleteProperty(this.customState, key); + }); + Object.assign(this.customState, preferences); + } + + private resolveCustomPreferencesDefaults( + extension: null | PreferencesExtension, + ) { + if (!extension) { + return {}; + } + + const result: CustomPreferencesRecord = {}; + + for (const field of extension.fields) { + result[field.key] = field.defaultValue; + } + + return result; + } + + private resolveNumericConstraint(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; + } + + private sanitizeCustomPreferences( + updates: DeepPartial, + ) { + if (!this.customPreferencesExtension) { + return {}; + } + + const result: CustomPreferencesRecord = {}; + + for (const field of this.customPreferencesExtension.fields) { + const value = updates[field.key]; + + if ( + value !== undefined && + this.isValidCustomPreferenceValue(field, value) + ) { + result[field.key] = value; + } + } + + return result; + } + /** * 保存偏好设置到缓存 - * @param preference - 要保存的偏好设置 */ - private saveToCache(preference: Preferences) { - this.cache.setItem(STORAGE_KEYS.MAIN, preference); - this.cache.setItem(STORAGE_KEYS.LOCALE, preference.app.locale); - this.cache.setItem(STORAGE_KEYS.THEME, preference.theme.mode); + private saveToCache() { + this.cache.setItem(STORAGE_KEYS.MAIN, this.state); + this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale); + this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode); + + if (this.customPreferencesExtension) { + this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState }); + return; + } + + this.cache.removeItem(STORAGE_KEYS.CUSTOM); } /** diff --git a/packages/@core/preferences/src/types.ts b/packages/@core/preferences/src/types.ts index ce0174ee6..efc275290 100644 --- a/packages/@core/preferences/src/types.ts +++ b/packages/@core/preferences/src/types.ts @@ -17,6 +17,84 @@ import type { } from '@vben-core/typings'; type SupportedLanguagesType = 'en-US' | 'zh-CN'; +type CustomPreferencesValue = boolean | number | string; + +interface CustomPreferencesOption { + label: string; + value: TValue; +} + +interface BaseCustomPreferencesField< + TKey extends string = string, + TValue extends CustomPreferencesValue = CustomPreferencesValue, +> { + componentProps?: Record; + defaultValue: TValue; + disabled?: boolean; + key: TKey; + label: string; + placeholder?: string; + tip?: string; +} + +interface CustomPreferencesInputField< + TKey extends string = string, +> extends BaseCustomPreferencesField { + component: 'input'; +} + +interface CustomPreferencesNumberField< + TKey extends string = string, +> extends BaseCustomPreferencesField { + component: 'number'; +} + +interface CustomPreferencesSelectField< + TKey extends string = string, +> extends BaseCustomPreferencesField { + component: 'select'; + options: CustomPreferencesOption[]; +} + +interface CustomPreferencesSwitchField< + TKey extends string = string, +> extends BaseCustomPreferencesField { + component: 'switch'; +} + +type CustomPreferencesRecord = Record; + +type AnyCustomPreferencesField = + | CustomPreferencesInputField + | CustomPreferencesNumberField + | CustomPreferencesSelectField + | CustomPreferencesSwitchField; + +type CustomPreferencesField< + TCustomPreferences extends object = CustomPreferencesRecord, +> = + string extends Extract + ? AnyCustomPreferencesField + : { + [K in Extract< + keyof TCustomPreferences, + string + >]: TCustomPreferences[K] extends boolean + ? CustomPreferencesSwitchField + : TCustomPreferences[K] extends number + ? CustomPreferencesNumberField + : TCustomPreferences[K] extends string + ? CustomPreferencesInputField | CustomPreferencesSelectField + : never; + }[Extract]; + +interface PreferencesExtension< + TCustomPreferences extends object = CustomPreferencesRecord, +> { + fields: Array>; + tabLabel: string; + title?: string; +} interface AppPreferences { /** 权限模式 */ @@ -326,19 +404,33 @@ interface Preferences { type PreferencesKeys = keyof Preferences; -interface InitialOptions { +interface InitialOptions< + TCustomPreferences extends object = CustomPreferencesRecord, +> { + extension?: PreferencesExtension; namespace: string; overrides?: DeepPartial; } export type { + AnyCustomPreferencesField, AppPreferences, + BaseCustomPreferencesField, BreadcrumbPreferences, + CustomPreferencesField, + CustomPreferencesInputField, + CustomPreferencesNumberField, + CustomPreferencesOption, + CustomPreferencesRecord, + CustomPreferencesSelectField, + CustomPreferencesSwitchField, + CustomPreferencesValue, FooterPreferences, HeaderPreferences, InitialOptions, LogoPreferences, NavigationPreferences, Preferences, + PreferencesExtension, PreferencesKeys, ShortcutKeyPreferences, SidebarPreferences, diff --git a/packages/@core/preferences/src/use-preferences.ts b/packages/@core/preferences/src/use-preferences.ts index b08eac192..8e6af6783 100644 --- a/packages/@core/preferences/src/use-preferences.ts +++ b/packages/@core/preferences/src/use-preferences.ts @@ -6,7 +6,13 @@ import { isDarkTheme } from './update-css-variables'; function usePreferences() { const preferences = preferencesManager.getPreferences(); + const customPreferences = preferencesManager.getCustomPreferences(); const initialPreferences = preferencesManager.getInitialPreferences(); + const initialCustomPreferences = + preferencesManager.getInitialCustomPreferences(); + const preferencesExtension = computed(() => + preferencesManager.getPreferencesExtension(), + ); /** * @zh_CN 计算偏好设置的变化 */ @@ -14,6 +20,10 @@ function usePreferences() { return diff(initialPreferences, preferences); }); + const diffCustomPreference = computed(() => { + return diff(initialCustomPreferences, customPreferences); + }); + const appPreferences = computed(() => preferences.app); const shortcutKeysPreferences = computed(() => preferences.shortcutKeys); @@ -227,7 +237,9 @@ function usePreferences() { authPanelLeft, authPanelRight, contentIsMaximize, + customPreferences, diffPreference, + diffCustomPreference, globalLockScreenShortcutKey, globalLogoutShortcutKey, globalSearchShortcutKey, @@ -244,6 +256,7 @@ function usePreferences() { keepAlive, layout, locale, + preferencesExtension, preferencesButtonPosition, sidebarCollapsed, theme, diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index 31aa3554c..a4c05b54e 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -44,6 +44,7 @@ describe('formApi', () => { await formApi.mount(formActions); expect(formApi.isMounted).toBe(true); expect(formApi.form).toEqual(formActions); + expect(formApi.getFieldComponentRef('name')).toBeUndefined(); }); it('should get values from form', async () => { @@ -52,11 +53,54 @@ describe('formApi', () => { values: { name: 'test' }, }; - await formApi.mount(formActions); + await formApi.mount(formActions, new Map()); const values = await formApi.getValues(); expect(values).toEqual({ name: 'test' }); }); + it('should format schema values when getting values', async () => { + formApi.setState({ + schema: [ + { + component: 'range-picker', + fieldName: 'filters.range', + valueFormat: (value, setValue) => { + setValue('filters.startTime', value?.[0]); + setValue('filters.endTime', value?.[1]); + }, + }, + ], + }); + + const formActions: any = { + meta: {}, + values: { + filters: { + range: [1_710_000_000_000, 1_720_000_000_000], + }, + }, + }; + const originalValuesSnapshot = structuredClone(formActions.values); + + await formApi.mount(formActions, new Map()); + + expect(formApi.getLatestSubmissionValues()).toEqual({ + filters: { + endTime: 1_720_000_000_000, + startTime: 1_710_000_000_000, + }, + }); + + const values = await formApi.getValues(); + expect(values).toEqual({ + filters: { + endTime: 1_720_000_000_000, + startTime: 1_710_000_000_000, + }, + }); + expect(formActions.values).toEqual(originalValuesSnapshot); + }); + it('should set field value', async () => { const setFieldValueMock = vi.fn(); const formActions: any = { @@ -65,7 +109,7 @@ describe('formApi', () => { values: { name: 'test' }, }; - await formApi.mount(formActions); + await formApi.mount(formActions, new Map()); await formApi.setFieldValue('name', 'new value'); expect(setFieldValueMock).toHaveBeenCalledWith( 'name', @@ -82,7 +126,7 @@ describe('formApi', () => { values: { name: 'test' }, }; - await formApi.mount(formActions); + await formApi.mount(formActions, new Map()); await formApi.resetForm(); expect(resetFormMock).toHaveBeenCalled(); }); @@ -100,7 +144,7 @@ describe('formApi', () => { }; formApi.setState(state); - await formApi.mount(formActions); + await formApi.mount(formActions, new Map()); const result = await formApi.submitForm(); expect(formActions.submitForm).toHaveBeenCalled(); @@ -113,6 +157,39 @@ describe('formApi', () => { expect(formApi.isMounted).toBe(false); }); + it('should clear component refs on unmount before mounting again', async () => { + const formActions: any = { + meta: {}, + resetForm: vi.fn(), + values: { name: 'test' }, + }; + const staleMap = new Map([ + [ + 'name', + { + $: { + type: { name: 'MockComponent' }, + }, + $el: {}, + }, + ], + ]); + + await formApi.mount(formActions, staleMap); + expect(formApi.getFieldComponentRef('name')).toEqual({ + $: { + type: { name: 'MockComponent' }, + }, + $el: {}, + }); + + formApi.unmount(); + expect(formApi.getFieldComponentRef('name')).toBeUndefined(); + + await formApi.mount(formActions); + expect(formApi.getFieldComponentRef('name')).toBeUndefined(); + }); + it('should validate form', async () => { const validateMock = vi.fn().mockResolvedValue(true); const formActions: any = { @@ -120,7 +197,7 @@ describe('formApi', () => { validate: validateMock, }; - await formApi.mount(formActions); + await formApi.mount(formActions, new Map()); const isValid = await formApi.validate(); expect(validateMock).toHaveBeenCalled(); expect(isValid).toBe(true); diff --git a/packages/@core/ui-kit/form-ui/package.json b/packages/@core/ui-kit/form-ui/package.json index f79c2a169..8becac7fb 100644 --- a/packages/@core/ui-kit/form-ui/package.json +++ b/packages/@core/ui-kit/form-ui/package.json @@ -51,5 +51,8 @@ "vue": "catalog:", "zod": "catalog:", "zod-defaults": "catalog:" + }, + "devDependencies": { + "unplugin-vue": "catalog:" } } diff --git a/packages/@core/ui-kit/form-ui/src/components/form-actions.vue b/packages/@core/ui-kit/form-ui/src/components/form-actions.vue index 36b1caa0c..d09e1a606 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-actions.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-actions.vue @@ -30,10 +30,6 @@ const submitButtonOptions = computed(() => { }; }); -// const isQueryForm = computed(() => { -// return !!unref(rootProps).showCollapseButton; -// }); - async function handleSubmit(e: Event) { e?.preventDefault(); e?.stopPropagation(); diff --git a/packages/@core/ui-kit/form-ui/src/field-name.ts b/packages/@core/ui-kit/form-ui/src/field-name.ts new file mode 100644 index 000000000..0e2a2da7d --- /dev/null +++ b/packages/@core/ui-kit/form-ui/src/field-name.ts @@ -0,0 +1,14 @@ +export function resolveFieldNamePath(fieldName: string) { + if (fieldName.startsWith('[') && fieldName.endsWith(']')) { + const rawKey = fieldName.slice(1, -1); + return { + pathSegments: [rawKey], + rawKey, + }; + } + + return { + pathSegments: fieldName.match(/[^.[\]]+/g) ?? [], + rawKey: undefined, + }; +} diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index e6c5a6bdb..1b842463a 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -16,16 +16,21 @@ import { isRef, toRaw } from 'vue'; import { Store } from '@vben-core/shared/store'; import { bindMethods, + cloneDeep, createMerge, formatDate, + get, isDate, isDayjsObject, isFunction, isObject, mergeWithArrayOverride, + set, StateHandler, } from '@vben-core/shared/utils'; +import { resolveFieldNamePath } from './field-name'; + function getDefaultState(): VbenFormProps { return { actionWrapperClass: '', @@ -158,7 +163,10 @@ export class FormApi { async getValues>() { const form = await this.getForm(); - return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T; + const values = form.values + ? this.handleRangeTimeValue(cloneDeep(toRaw(form.values))) + : {}; + return this.handleValueFormat(values) as T; } async isFieldValid(fieldName: string) { @@ -206,14 +214,18 @@ export class FormApi { return proxy; } - mount(formActions: FormActions, componentRefMap: Map) { + mount(formActions: FormActions, componentRefMap?: Map) { if (!this.isMounted) { Object.assign(this.form, formActions); this.stateHandler.setConditionTrue(); + const initialValues = this.form.values + ? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values))) + : {}; this.setLatestSubmissionValues({ - ...toRaw(this.handleRangeTimeValue(this.form.values)), + ...this.handleValueFormat(initialValues), }); - this.componentRefMap = componentRefMap; + this.componentRefMap = + componentRefMap ?? this.componentRefMap ?? new Map(); this.isMounted = true; } } @@ -363,6 +375,7 @@ export class FormApi { unmount() { this.form?.resetForm?.(); // this.state = null; + this.componentRefMap = new Map(); this.latestSubmissionValues = null; this.isMounted = false; this.stateHandler.reset(); @@ -443,6 +456,42 @@ export class FormApi { return validateResult; } + private deleteValueByFieldName( + values: Record, + fieldName: string, + ) { + const { pathSegments, rawKey } = resolveFieldNamePath(fieldName); + if (rawKey) { + Reflect.deleteProperty(values, rawKey); + return; + } + + if (!pathSegments || pathSegments.length === 0) { + Reflect.deleteProperty(values, fieldName); + return; + } + + let target: Record | undefined = values; + + for (const segment of pathSegments.slice(0, -1)) { + if (!target || !isObject(target)) { + return; + } + target = target[segment]; + } + + if (!target || !isObject(target)) { + return; + } + + const lastPathSegment = pathSegments.at(-1); + if (!lastPathSegment) { + return; + } + + Reflect.deleteProperty(target, lastPathSegment); + } + private async getForm() { if (!this.isMounted) { // 等待form挂载 @@ -560,6 +609,36 @@ export class FormApi { return values; }; + private handleValueFormat = (originValues: Record) => { + const values = { ...originValues }; + const currentSchema = this.state?.schema ?? []; + + currentSchema.forEach((schema) => { + if (!schema.valueFormat) { + return; + } + + const fieldName = schema.fieldName; + const value = this.resolveValueByFieldName(values, fieldName); + + this.deleteValueByFieldName(values, fieldName); + + const formattedValue = schema.valueFormat( + value, + (key, nextValue) => { + this.setValueByFieldName(values, key, nextValue); + }, + values, + ); + + if (formattedValue !== undefined) { + this.setValueByFieldName(values, fieldName, formattedValue); + } + }); + + return values; + }; + private processFields = ( fields: string[], separator: string, @@ -575,6 +654,32 @@ export class FormApi { }); }; + private resolveValueByFieldName( + values: Record, + fieldName: string, + ) { + const { rawKey } = resolveFieldNamePath(fieldName); + if (rawKey) { + return values[rawKey]; + } + + return get(values, fieldName); + } + + private setValueByFieldName( + values: Record, + fieldName: string, + value: any, + ) { + const { rawKey } = resolveFieldNamePath(fieldName); + if (rawKey) { + values[rawKey] = value; + return; + } + + set(values, fieldName, value); + } + private updateState() { const currentSchema = this.state?.schema ?? []; const prevSchema = this.prevState?.schema ?? []; diff --git a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts index 2a330c9f6..505bc9eef 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts +++ b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts @@ -1,15 +1,18 @@ import type { + ExtendedFormApi, FormItemDependencies, FormSchemaRuleType, MaybeComponentProps, } from '../types'; -import { computed, ref, watch } from 'vue'; +import { computed, isRef, ref, watch } from 'vue'; import { get, isBoolean, isFunction } from '@vben-core/shared/utils'; import { useFormValues } from 'vee-validate'; +import { resolveFieldNamePath } from '../field-name'; +import { injectFormProps } from '../use-form-context'; import { injectRenderFormProps } from './context'; /** @@ -22,20 +25,21 @@ function resolveValueByFieldName( fieldName: string, ) { // vee-validate:[] 表示禁用嵌套 - if (fieldName.startsWith('[') && fieldName.endsWith(']')) { - const rawKey = fieldName.slice(1, -1); + const { rawKey } = resolveFieldNamePath(fieldName); + if (rawKey) { return values[rawKey]; } return get(values, fieldName); } - export default function useDependencies( getDependencies: () => FormItemDependencies | undefined, ) { const values = useFormValues(); + const [extendApi] = injectFormProps(); const formRenderProps = injectRenderFormProps(); + const formApi = formRenderProps.form; if (!formApi) { @@ -46,6 +50,19 @@ export default function useDependencies( throw new Error('useDependencies should be used within '); } + // 在 dependencies 里提供访问extendApi的能力 + const getController = (): ExtendedFormApi => { + const controller = isRef(extendApi) + ? extendApi.value.formApi + : extendApi.formApi; + + if (!controller) { + throw new Error('formApi is required in useDependencies'); + } + + return controller; + }; + const isIf = ref(true); const isDisabled = ref(false); const isShow = ref(true); @@ -91,7 +108,7 @@ export default function useDependencies( const formValues = values.value; if (isFunction(whenIf)) { - isIf.value = !!(await whenIf(formValues, formApi)); + isIf.value = !!(await whenIf(formValues, formApi, getController())); // 不渲染 if (!isIf.value) return; } else if (isBoolean(whenIf)) { @@ -101,31 +118,43 @@ export default function useDependencies( // 2. 判断show,如果show为false,则隐藏 if (isFunction(show)) { - isShow.value = !!(await show(formValues, formApi)); + isShow.value = !!(await show(formValues, formApi, getController())); } else if (isBoolean(show)) { isShow.value = show; } if (isFunction(componentProps)) { - dynamicComponentProps.value = await componentProps(formValues, formApi); + dynamicComponentProps.value = await componentProps( + formValues, + formApi, + getController(), + ); } if (isFunction(rules)) { - dynamicRules.value = await rules(formValues, formApi); + dynamicRules.value = await rules(formValues, formApi, getController()); } if (isFunction(disabled)) { - isDisabled.value = !!(await disabled(formValues, formApi)); + isDisabled.value = !!(await disabled( + formValues, + formApi, + getController(), + )); } else if (isBoolean(disabled)) { isDisabled.value = disabled; } if (isFunction(required)) { - isRequired.value = !!(await required(formValues, formApi)); + isRequired.value = !!(await required( + formValues, + formApi, + getController(), + )); } if (isFunction(trigger)) { - trigger(formValues, formApi); + trigger(formValues, formApi, getController()); } }, { deep: true, immediate: true }, diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue index 004a5ee81..2a25259bc 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue @@ -7,15 +7,24 @@ import type { MaybeComponentProps, } from '../types'; -import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue'; - -import { CircleAlert } from '@vben-core/icons'; import { + computed, + nextTick, + onUnmounted, + ref, + useTemplateRef, + watch, +} from 'vue'; + +import { ChevronsDown, CircleAlert } from '@vben-core/icons'; +import { + Button, FormControl, FormDescription, FormField, FormItem, FormMessage, + VbenCollapsible, VbenRenderContent, VbenTooltip, } from '@vben-core/shadcn-ui'; @@ -53,6 +62,8 @@ const { renderComponentContent, rules, help, + collapsible, + defaultCollapsed = false, } = defineProps< Props & { commonComponentProps: MaybeComponentProps; @@ -67,6 +78,7 @@ const fieldComponentRef = useTemplateRef('fieldComponentRef'); const formApi = formRenderProps.form; const compact = computed(() => formRenderProps.compact); const isInValid = computed(() => errors.value?.length > 0); +const collapseOpen = ref(!defaultCollapsed); function getFormApi(): FormActions { if (!formApi) { @@ -77,7 +89,9 @@ function getFormApi(): FormActions { } const FieldComponent = computed(() => { - const finalComponent = isString(component) ? componentMap.value[component] : component; + const finalComponent = isString(component) + ? componentMap.value[component] + : component; if (!finalComponent) { // 组件未注册 console.warn(`Component ${component} is not registered`); @@ -85,8 +99,14 @@ const FieldComponent = computed(() => { return finalComponent; }); -const { dynamicComponentProps, dynamicRules, isDisabled, isIf, isRequired, isShow } = - useDependencies(() => dependencies); +const { + dynamicComponentProps, + dynamicRules, + isDisabled, + isIf, + isRequired, + isShow, +} = useDependencies(() => dependencies); const labelStyle = computed(() => { return labelClass?.includes('w-') || isVertical.value @@ -225,7 +245,8 @@ function fieldBindEvent(slotProps: Record) { const handler = slotProps.componentField['onUpdate:modelValue']; const bindEventField = - modelPropName || (isString(component) ? componentBindEventMap.value?.[component] : null); + modelPropName || + (isString(component) ? componentBindEventMap.value?.[component] : null); let value = modelValue; // antd design 的一些组件会传递一个 event 对象 @@ -287,6 +308,15 @@ function autofocus() { fieldComponentRef.value?.focus?.(); } } + +const shouldCollapsible = computed(() => { + return collapsible; /* && isVertical.value; */ +}); + +function toggleCollapsed() { + collapseOpen.value = !collapseOpen.value; +} + const componentRefMap = injectComponentRefMap(); watch(fieldComponentRef, (componentRef) => { componentRefMap?.set(fieldName, componentRef); @@ -299,7 +329,12 @@ onUnmounted(() => { - - -
-
- - + - + + + + diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-label.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-label.vue index adc6d646b..a52c44821 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-label.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-label.vue @@ -26,6 +26,7 @@ const props = defineProps(); + : diff --git a/packages/@core/ui-kit/form-ui/src/index.ts b/packages/@core/ui-kit/form-ui/src/index.ts index 67ed4a50e..9bb3fd33c 100644 --- a/packages/@core/ui-kit/form-ui/src/index.ts +++ b/packages/@core/ui-kit/form-ui/src/index.ts @@ -3,8 +3,9 @@ export { setupVbenForm } from './config'; export type { BaseFormComponentType, ExtendedFormApi, - FormSchema as VbenFormSchema, + FormLayout, VbenFormProps, + FormSchema as VbenFormSchema, } from './types'; export * from './use-vben-form'; diff --git a/packages/@core/ui-kit/form-ui/src/types.ts b/packages/@core/ui-kit/form-ui/src/types.ts index e30e8f14f..41ef5f329 100644 --- a/packages/@core/ui-kit/form-ui/src/types.ts +++ b/packages/@core/ui-kit/form-ui/src/types.ts @@ -85,16 +85,19 @@ export type FormSchemaRuleType = type FormItemDependenciesCondition> = ( value: Partial>, actions: FormActions, + controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力 ) => T; type FormItemDependenciesConditionWithRules = ( value: Partial>, actions: FormActions, + controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力 ) => FormSchemaRuleType | PromiseLike; type FormItemDependenciesConditionWithProps = ( value: Partial>, actions: FormActions, + controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力 ) => MaybeComponentProps | PromiseLike; export interface FormItemDependencies { @@ -145,6 +148,11 @@ type ComponentProps = | MaybeComponentProps; export interface FormCommonConfig { + /** + * 是否可折叠的 + * @default false + */ + collapsible?: boolean; /** * 在Label后显示一个冒号 */ @@ -157,6 +165,11 @@ export interface FormCommonConfig { * 所有表单项的控件样式 */ controlClass?: string; + /** + * 默认折叠 + * @default false + */ + defaultCollapsed?: boolean; /** * 所有表单项的禁用状态 * @default false @@ -228,6 +241,19 @@ type MappedComponentProps

= ) => P & Record) | (P & Record); +/** + * 格式化 `getValues()` 输出中的当前字段值。 + * - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)` + * 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime` + * - 返回其他值:会将当前字段恢复/写回为该返回值 + * - `setValue` 回调签名为 `(key, nextValue) => void` + */ +export type FormValueFormat = ( + value: any, + setValue: (fieldName: string, value: any) => void, + values: Record, +) => any; + interface FormSchemaBody extends Omit { /** 默认值 */ defaultValue?: any; @@ -249,6 +275,12 @@ interface FormSchemaBody extends Omit { rules?: FormSchemaRuleType; /** 后缀 */ suffix?: CustomRenderType; + /** + * 获取表单值时格式化当前字段。 + * - 返回值不为 `undefined` 时,会回写到当前 fieldName + * - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段 + */ + valueFormat?: FormValueFormat; } type FormSchemaDiscriminated< diff --git a/packages/@core/ui-kit/layout-ui/package.json b/packages/@core/ui-kit/layout-ui/package.json index efeba2269..e579303d9 100644 --- a/packages/@core/ui-kit/layout-ui/package.json +++ b/packages/@core/ui-kit/layout-ui/package.json @@ -47,5 +47,8 @@ "@vben-core/typings": "workspace:*", "@vueuse/core": "catalog:", "vue": "catalog:" + }, + "devDependencies": { + "unplugin-vue": "catalog:" } } diff --git a/packages/@core/ui-kit/menu-ui/package.json b/packages/@core/ui-kit/menu-ui/package.json index a1dbc1b2a..5290fb1a8 100644 --- a/packages/@core/ui-kit/menu-ui/package.json +++ b/packages/@core/ui-kit/menu-ui/package.json @@ -51,6 +51,7 @@ "vue": "catalog:" }, "devDependencies": { - "@types/qs": "catalog:" + "@types/qs": "catalog:", + "unplugin-vue": "catalog:" } } diff --git a/packages/@core/ui-kit/popup-ui/package.json b/packages/@core/ui-kit/popup-ui/package.json index 0e384cbf3..865a83c07 100644 --- a/packages/@core/ui-kit/popup-ui/package.json +++ b/packages/@core/ui-kit/popup-ui/package.json @@ -47,5 +47,8 @@ "@vben-core/typings": "workspace:*", "@vueuse/core": "catalog:", "vue": "catalog:" + }, + "devDependencies": { + "unplugin-vue": "catalog:" } } diff --git a/packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts b/packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts index 5d8523b4d..85f02e27d 100644 --- a/packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts +++ b/packages/@core/ui-kit/popup-ui/src/drawer/drawer.ts @@ -79,7 +79,7 @@ export interface DrawerProps { */ headerClass?: ClassType; /** - * 弹窗是否显示 + * 抽屉加载状态 * @default false */ loading?: boolean; diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts b/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts index 74758913d..c83d7095f 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal-api.ts @@ -46,6 +46,7 @@ export class ModalApi { contentClass: '', destroyOnClose: true, draggable: false, + overflow: false, footer: true, footerClass: '', fullscreen: false, diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal.ts b/packages/@core/ui-kit/popup-ui/src/modal/modal.ts index 4debaff4b..5a7a50286 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal.ts +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal.ts @@ -97,7 +97,7 @@ export interface ModalProps { header?: boolean; headerClass?: ClassType; /** - * 弹窗是否显示 + * 弹窗加载状态 * @default false */ loading?: boolean; @@ -110,6 +110,11 @@ export interface ModalProps { * 是否自动聚焦 */ openAutoFocus?: boolean; + /** + * 拖动范围是否可以超出可视区 + * @default false + */ + overflow?: boolean; /** * 弹窗遮罩模糊效果 */ diff --git a/packages/@core/ui-kit/popup-ui/src/modal/modal.vue b/packages/@core/ui-kit/popup-ui/src/modal/modal.vue index f63d86018..1947b2254 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/modal.vue +++ b/packages/@core/ui-kit/popup-ui/src/modal/modal.vue @@ -81,6 +81,7 @@ const { description, destroyOnClose, draggable, + overflow, footer: showFooter, footerClass, fullscreen, @@ -122,6 +123,7 @@ const { dragging, transform } = useModalDraggable( shouldDraggable, getAppendTo, shouldCentered, + overflow, ); const firstOpened = ref(false); @@ -246,7 +248,8 @@ function handleClosed() { { 'border border-border': bordered, 'shadow-3xl': !bordered, - 'top-0 left-0 size-full max-h-full translate-0!': shouldFullscreen, + 'top-0 left-0 size-full max-h-full transform-[translate(0,0)]!': + shouldFullscreen, 'top-1/2': centered && !shouldFullscreen, 'duration-300': !dragging, hidden: isClosed, diff --git a/packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts b/packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts index 84910b744..a7bcba555 100644 --- a/packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts +++ b/packages/@core/ui-kit/popup-ui/src/modal/use-modal-draggable.ts @@ -15,6 +15,7 @@ export function useModalDraggable( draggable: ComputedRef, containerSelector?: ComputedRef, centered?: ComputedRef, + overflow?: ComputedRef, ) { const transform = reactive({ offsetX: 0, @@ -67,8 +68,10 @@ export function useModalDraggable( let moveX = offsetX + e.clientX - downX; let moveY = offsetY + e.clientY - downY; - moveX = Math.min(Math.max(moveX, minLeft), maxLeft); - moveY = Math.min(Math.max(moveY, minTop), maxTop); + if (!overflow?.value) { + moveX = Math.min(Math.max(moveX, minLeft), maxLeft); + moveY = Math.min(Math.max(moveY, minTop), maxTop); + } transform.offsetX = moveX; transform.offsetY = moveY; diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params-item.vue b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params-item.vue new file mode 100644 index 000000000..85b019718 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params-item.vue @@ -0,0 +1,123 @@ + + + diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params.vue b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params.vue new file mode 100644 index 000000000..8c0accac4 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible-params.vue @@ -0,0 +1,263 @@ + + + + diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible.vue b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible.vue new file mode 100755 index 000000000..f76dbf13e --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/collapsible.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/index.ts b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/index.ts new file mode 100755 index 000000000..8563a7e59 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/index.ts @@ -0,0 +1,4 @@ +export { default as VbenCollapsibleParams } from './collapsible-params.vue'; +export { default as VbenCollapsible } from './collapsible.vue'; + +export * from './type'; diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/type.ts b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/type.ts new file mode 100644 index 000000000..f2fcffdf3 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/components/collapsible/type.ts @@ -0,0 +1,22 @@ +export interface CollapsibleParamsProps { + defaultOpen?: boolean; + maxHeight?: number | string; + params: CollapsibleParamSchema[]; + visibleCount?: number; +} + +export interface CollapsibleParamOption { + [key: string]: any; + max?: number; + min?: number; + precision?: number; + step?: number; + type?: 'exponential' | 'number' | 'select' | 'string'; +} + +export interface CollapsibleParamSchema { + defaultValue?: number | number[] | string | string[]; + description: string; + key: string; + option: CollapsibleParamOption; +} diff --git a/packages/@core/ui-kit/shadcn-ui/src/components/index.ts b/packages/@core/ui-kit/shadcn-ui/src/components/index.ts index 034bbbca7..ebbdbcc19 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/components/index.ts +++ b/packages/@core/ui-kit/shadcn-ui/src/components/index.ts @@ -3,6 +3,7 @@ export * from './back-top'; export * from './breadcrumb'; export * from './button'; export * from './checkbox'; +export * from './collapsible'; export * from './context-menu'; export * from './count-to-animator'; export * from './dropdown-menu'; diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/button/button.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/button/button.ts index d02877518..0213f2ff8 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/button/button.ts +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/button/button.ts @@ -1,7 +1,7 @@ import { cva } from 'class-variance-authority'; export const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', { defaultVariants: { size: 'default', diff --git a/packages/@core/ui-kit/tabs-ui/package.json b/packages/@core/ui-kit/tabs-ui/package.json index 23e37f0a4..75d0fd854 100644 --- a/packages/@core/ui-kit/tabs-ui/package.json +++ b/packages/@core/ui-kit/tabs-ui/package.json @@ -47,5 +47,8 @@ "@vben-core/typings": "workspace:*", "@vueuse/core": "catalog:", "vue": "catalog:" + }, + "devDependencies": { + "unplugin-vue": "catalog:" } } diff --git a/packages/effects/common-ui/package.json b/packages/effects/common-ui/package.json index 4dae68c62..fcb893034 100644 --- a/packages/effects/common-ui/package.json +++ b/packages/effects/common-ui/package.json @@ -63,6 +63,7 @@ }, "devDependencies": { "@types/json-bigint": "catalog:", - "@types/qrcode": "catalog:" + "@types/qrcode": "catalog:", + "@vue/test-utils": "catalog:" } } diff --git a/packages/effects/common-ui/src/components/api-component/api-component.vue b/packages/effects/common-ui/src/components/api-component/api-component.vue index 45ec260f4..2c6d69317 100644 --- a/packages/effects/common-ui/src/components/api-component/api-component.vue +++ b/packages/effects/common-ui/src/components/api-component/api-component.vue @@ -17,6 +17,7 @@ defineOptions({ name: 'ApiComponent', inheritAttrs: false }); const props = withDefaults(defineProps(), { labelField: 'label', valueField: 'value', + labelFn: undefined, disabledField: 'disabled', childrenField: '', optionsPropName: 'options', @@ -54,33 +55,37 @@ const hasPendingRequest = ref(false); const getOptions = computed(() => { const { labelField, + labelFn, valueField, disabledField, childrenField, numberToString, } = props; - const refOptionsData = unref(refOptions); - - function transformData(data: OptionsItem[]): OptionsItem[] { + function transformData(data: OptionsItem[] = []): OptionsItem[] { return data.map((item) => { const value = get(item, valueField); - const disabled = get(item, disabledField); + const children = childrenField ? get(item, childrenField) : item.children; return { - ...objectOmit(item, [labelField, valueField, disabled, childrenField]), - label: get(item, labelField), + ...objectOmit(item, [ + labelField, + valueField, + disabledField, + ...(childrenField ? [childrenField] : []), + ]), + label: labelFn ? labelFn(item) : get(item, labelField), value: numberToString ? `${value}` : value, disabled: get(item, disabledField), - ...(childrenField && item[childrenField] - ? { children: transformData(item[childrenField]) } + ...(Array.isArray(children) && children.length > 0 + ? { children: transformData(children) } : {}), }; }); } - const data: OptionsItem[] = transformData(refOptionsData); + const data = transformData(unref(refOptions)); - return data.length > 0 ? data : props.options; + return data.length > 0 ? data : transformData(props.options); }); const bindProps = computed(() => { diff --git a/packages/effects/common-ui/src/components/api-component/index.ts b/packages/effects/common-ui/src/components/api-component/index.ts index 097858698..173b30d1c 100644 --- a/packages/effects/common-ui/src/components/api-component/index.ts +++ b/packages/effects/common-ui/src/components/api-component/index.ts @@ -1,5 +1,6 @@ export { default as ApiComponent } from './api-component.vue'; export type { + ApiComponentLabelFn, ApiComponentOptionsItem, ApiComponentProps, ApiComponentSharedProps, diff --git a/packages/effects/common-ui/src/components/api-component/types.ts b/packages/effects/common-ui/src/components/api-component/types.ts index 000636a7f..a771f78a5 100644 --- a/packages/effects/common-ui/src/components/api-component/types.ts +++ b/packages/effects/common-ui/src/components/api-component/types.ts @@ -10,6 +10,8 @@ export type ApiComponentOptionsItem = { value?: number | string; }; +export type ApiComponentLabelFn = (item: ApiComponentOptionsItem) => string; + export interface ApiComponentProps { /** 组件 */ component: Component; @@ -23,6 +25,8 @@ export interface ApiComponentProps { resultField?: string; /** label字段名 */ labelField?: string; + /** 通过选项数据自定义label */ + labelFn?: ApiComponentLabelFn; /** children字段名,需要层级数据的组件可用 */ childrenField?: string; /** value字段名 */ diff --git a/packages/effects/common-ui/src/components/index.ts b/packages/effects/common-ui/src/components/index.ts index 13fc3c7a7..774f21891 100644 --- a/packages/effects/common-ui/src/components/index.ts +++ b/packages/effects/common-ui/src/components/index.ts @@ -27,6 +27,7 @@ export { VbenContextMenu, VbenCountToAnimator, VbenFullScreen, + VbenIconButton, VbenInputPassword, VbenLoading, VbenLogo, diff --git a/packages/effects/layouts/src/widgets/notification/notification.vue b/packages/effects/layouts/src/widgets/notification/notification.vue index a399932ff..372b1f586 100644 --- a/packages/effects/layouts/src/widgets/notification/notification.vue +++ b/packages/effects/layouts/src/widgets/notification/notification.vue @@ -1,67 +1,69 @@