ソースを参照

feat: 修改框架

DESKTOP-USV654P\pc 9 ヶ月 前
コミット
a630a36adc
100 ファイル変更7360 行追加0 行削除
  1. 11 0
      apps/baicai-cms/.env
  2. 7 0
      apps/baicai-cms/.env.analyze
  3. 16 0
      apps/baicai-cms/.env.development
  4. 19 0
      apps/baicai-cms/.env.production
  5. 35 0
      apps/baicai-cms/index.html
  6. 59 0
      apps/baicai-cms/package.json
  7. 108 0
      apps/baicai-cms/playwright.config.ts
  8. 1 0
      apps/baicai-cms/postcss.config.mjs
  9. BIN
      apps/baicai-cms/public/favicon.ico
  10. 211 0
      apps/baicai-cms/src/adapter/component/index.ts
  11. 47 0
      apps/baicai-cms/src/adapter/form.ts
  12. 40 0
      apps/baicai-cms/src/adapter/index.ts
  13. 426 0
      apps/baicai-cms/src/adapter/vxe-table.ts
  14. 62 0
      apps/baicai-cms/src/api/core/auth.ts
  15. 3 0
      apps/baicai-cms/src/api/core/index.ts
  16. 15 0
      apps/baicai-cms/src/api/core/menu.ts
  17. 10 0
      apps/baicai-cms/src/api/core/user.ts
  18. 3 0
      apps/baicai-cms/src/api/examples/index.ts
  19. 10 0
      apps/baicai-cms/src/api/examples/status.ts
  20. 18 0
      apps/baicai-cms/src/api/examples/table.ts
  21. 25 0
      apps/baicai-cms/src/api/examples/upload.ts
  22. 3 0
      apps/baicai-cms/src/api/index.ts
  23. 132 0
      apps/baicai-cms/src/api/model/index.ts
  24. 124 0
      apps/baicai-cms/src/api/request.ts
  25. 1 0
      apps/baicai-cms/src/api/site/index.ts
  26. 17 0
      apps/baicai-cms/src/api/site/site.ts
  27. 39 0
      apps/baicai-cms/src/app.vue
  28. 76 0
      apps/baicai-cms/src/bootstrap.ts
  29. 1 0
      apps/baicai-cms/src/components/bc-tree/index.ts
  30. 428 0
      apps/baicai-cms/src/components/bc-tree/src/bc-tree.vue
  31. 9 0
      apps/baicai-cms/src/components/bc-tree/src/types.ts
  32. 101 0
      apps/baicai-cms/src/components/bc-tree/src/useTree.ts
  33. 32 0
      apps/baicai-cms/src/components/form/component-map.ts
  34. 120 0
      apps/baicai-cms/src/components/form/components/bc-checkbox.vue
  35. 192 0
      apps/baicai-cms/src/components/form/components/bc-editor/bc-editor.vue
  36. 94 0
      apps/baicai-cms/src/components/form/components/bc-icon-picker.vue
  37. 126 0
      apps/baicai-cms/src/components/form/components/bc-radio.vue
  38. 156 0
      apps/baicai-cms/src/components/form/components/bc-select.vue
  39. 127 0
      apps/baicai-cms/src/components/form/components/bc-tree-select.vue
  40. 88 0
      apps/baicai-cms/src/components/form/components/input-code.vue
  41. 147 0
      apps/baicai-cms/src/components/form/components/input-code/input-code-modal-ignore.vue
  42. 32 0
      apps/baicai-cms/src/components/form/helper.ts
  43. 19 0
      apps/baicai-cms/src/components/form/types/index.d.ts
  44. 793 0
      apps/baicai-cms/src/components/icon/icon.data.ts
  45. 52 0
      apps/baicai-cms/src/components/icon/icon.vue
  46. 1 0
      apps/baicai-cms/src/components/icon/index.ts
  47. BIN
      apps/baicai-cms/src/components/select-card/images/head-default.png
  48. BIN
      apps/baicai-cms/src/components/select-card/images/head-female.png
  49. BIN
      apps/baicai-cms/src/components/select-card/images/head-male.png
  50. BIN
      apps/baicai-cms/src/components/select-card/images/role.png
  51. 1 0
      apps/baicai-cms/src/components/select-card/index.ts
  52. 176 0
      apps/baicai-cms/src/components/select-card/src/select-card-item.vue
  53. 277 0
      apps/baicai-cms/src/components/select-card/src/select-card.vue
  54. 2 0
      apps/baicai-cms/src/components/table-action/index.ts
  55. 263 0
      apps/baicai-cms/src/components/table-action/src/table-action.vue
  56. 28 0
      apps/baicai-cms/src/components/table-action/src/types.d.ts
  57. 25 0
      apps/baicai-cms/src/layouts/auth.vue
  58. 194 0
      apps/baicai-cms/src/layouts/basic.vue
  59. 1 0
      apps/baicai-cms/src/layouts/empty/index.ts
  60. 40 0
      apps/baicai-cms/src/layouts/empty/layout.vue
  61. 10 0
      apps/baicai-cms/src/layouts/index.ts
  62. 1 0
      apps/baicai-cms/src/layouts/page/index.ts
  63. 51 0
      apps/baicai-cms/src/layouts/page/layout.vue
  64. 1 0
      apps/baicai-cms/src/layouts/page/menu/index.ts
  65. 45 0
      apps/baicai-cms/src/layouts/page/menu/menu.vue
  66. 169 0
      apps/baicai-cms/src/layouts/page/menu/use-mixed-menu.ts
  67. 63 0
      apps/baicai-cms/src/layouts/page/menu/use-navigation.ts
  68. 3 0
      apps/baicai-cms/src/locales/README.md
  69. 102 0
      apps/baicai-cms/src/locales/index.ts
  70. 125 0
      apps/baicai-cms/src/locales/langs/en-US.json
  71. 70 0
      apps/baicai-cms/src/locales/langs/en-US/demos.json
  72. 70 0
      apps/baicai-cms/src/locales/langs/en-US/examples.json
  73. 16 0
      apps/baicai-cms/src/locales/langs/en-US/page.json
  74. 125 0
      apps/baicai-cms/src/locales/langs/zh-CN.json
  75. 70 0
      apps/baicai-cms/src/locales/langs/zh-CN/demos.json
  76. 70 0
      apps/baicai-cms/src/locales/langs/zh-CN/examples.json
  77. 16 0
      apps/baicai-cms/src/locales/langs/zh-CN/page.json
  78. 31 0
      apps/baicai-cms/src/main.ts
  79. 18 0
      apps/baicai-cms/src/preferences.ts
  80. 44 0
      apps/baicai-cms/src/router/access.ts
  81. 131 0
      apps/baicai-cms/src/router/guard.ts
  82. 36 0
      apps/baicai-cms/src/router/index.ts
  83. 152 0
      apps/baicai-cms/src/router/routes/core.ts
  84. 49 0
      apps/baicai-cms/src/router/routes/index.ts
  85. 31 0
      apps/baicai-cms/src/router/routes/modules/dashboard.ts
  86. 94 0
      apps/baicai-cms/src/router/routes/modules/vben.ts
  87. 34 0
      apps/baicai-cms/src/store/app.ts
  88. 151 0
      apps/baicai-cms/src/store/auth.ts
  89. 2 0
      apps/baicai-cms/src/store/index.ts
  90. 7 0
      apps/baicai-cms/src/utils/cryptogram.ts
  91. 3 0
      apps/baicai-cms/src/utils/index.ts
  92. 45 0
      apps/baicai-cms/src/utils/tree.ts
  93. 139 0
      apps/baicai-cms/src/utils/utils.ts
  94. 3 0
      apps/baicai-cms/src/views/_core/README.md
  95. 9 0
      apps/baicai-cms/src/views/_core/about/index.vue
  96. 109 0
      apps/baicai-cms/src/views/_core/authentication/code-login.vue
  97. 42 0
      apps/baicai-cms/src/views/_core/authentication/forget-password.vue
  98. 74 0
      apps/baicai-cms/src/views/_core/authentication/login.vue
  99. 10 0
      apps/baicai-cms/src/views/_core/authentication/qrcode-login.vue
  100. 96 0
      apps/baicai-cms/src/views/_core/authentication/register.vue

+ 11 - 0
apps/baicai-cms/.env

@@ -0,0 +1,11 @@
+# 应用标题
+VITE_APP_TITLE=Baicai CMS
+
+# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
+VITE_APP_NAMESPACE=baicai-cms
+
+# 公钥
+VITE_GLOB_PUBLIC_KEY = 04A8C613723930314A9067B690148EB526650109E5F8390EE22AA97BEFEF8BD38E6AAA4762392BB5360BF1F7895D66A83F72EA3FD1D5350BC2394E285088E23E8F
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

+ 7 - 0
apps/baicai-cms/.env.analyze

@@ -0,0 +1,7 @@
+# public path
+VITE_BASE=/
+
+# Basic interface address SPA
+VITE_GLOB_API_URL=/api
+
+VITE_VISUALIZER=true

+ 16 - 0
apps/baicai-cms/.env.development

@@ -0,0 +1,16 @@
+# 端口号
+VITE_PORT=5888
+
+VITE_BASE=/
+
+# 接口地址
+VITE_GLOB_API_URL=https://localhost:5001/api
+
+# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
+VITE_NITRO_MOCK=true
+
+# 是否打开 devtools,true 为打开,false 为关闭
+VITE_DEVTOOLS=false
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true

+ 19 - 0
apps/baicai-cms/.env.production

@@ -0,0 +1,19 @@
+VITE_BASE=/
+
+# 接口地址
+VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
+
+# 是否开启压缩,可以设置为 none, brotli, gzip
+VITE_COMPRESS=none
+
+# 是否开启 PWA
+VITE_PWA=false
+
+# vue-router 的模式
+VITE_ROUTER_HISTORY=hash
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true
+
+# 打包后是否生成dist.zip
+VITE_ARCHIVER=true

+ 35 - 0
apps/baicai-cms/index.html

@@ -0,0 +1,35 @@
+<!doctype html>
+<html lang="zh">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <meta name="renderer" content="webkit" />
+    <meta name="description" content="A Modern Back-end Management System" />
+    <meta name="keywords" content="Vben Admin Vue3 Vite" />
+    <meta name="author" content="Vben" />
+    <meta
+      name="viewport"
+      content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
+    />
+    <!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
+    <title><%= VITE_APP_TITLE %></title>
+    <link rel="icon" href="/favicon.ico" />
+    <script>
+      // 生产环境下注入百度统计
+      if (window._VBEN_ADMIN_PRO_APP_CONF_) {
+        var _hmt = _hmt || [];
+        (function () {
+          var hm = document.createElement('script');
+          hm.src =
+            'https://hm.baidu.com/hm.js?d20a01273820422b6aa2ee41b6c9414d';
+          var s = document.getElementsByTagName('script')[0];
+          s.parentNode.insertBefore(hm, s);
+        })();
+      }
+    </script>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 59 - 0
apps/baicai-cms/package.json

@@ -0,0 +1,59 @@
+{
+  "name": "@vben/baicai-cms",
+  "version": "5.5.6",
+  "homepage": "https://vben.pro",
+  "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vbenjs/vue-vben-admin.git",
+    "directory": "apps/baicai-cms"
+  },
+  "license": "MIT",
+  "author": {
+    "name": "vben",
+    "email": "ann.vben@gmail.com",
+    "url": "https://github.com/anncwb"
+  },
+  "type": "module",
+  "scripts": {
+    "build": "pnpm vite build --mode production",
+    "build:analyze": "pnpm vite build --mode analyze",
+    "dev": "pnpm vite --mode development",
+    "preview": "vite preview",
+    "typecheck": "vue-tsc --noEmit --skipLibCheck",
+    "test:e2e": "playwright test",
+    "test:e2e-ui": "playwright test --ui",
+    "test:e2e-codegen": "playwright codegen"
+  },
+  "imports": {
+    "#/*": "./src/*"
+  },
+  "dependencies": {
+    "@tanstack/vue-query": "catalog:",
+    "@vben-core/menu-ui": "workspace:*",
+    "@vben/access": "workspace:*",
+    "@vben/common-ui": "workspace:*",
+    "@vben/constants": "workspace:*",
+    "@vben/hooks": "workspace:*",
+    "@vben/icons": "workspace:*",
+    "@vben/layouts": "workspace:*",
+    "@vben/locales": "workspace:*",
+    "@vben/plugins": "workspace:*",
+    "@vben/preferences": "workspace:*",
+    "@vben/request": "workspace:*",
+    "@vben/stores": "workspace:*",
+    "@vben/styles": "workspace:*",
+    "@vben/types": "workspace:*",
+    "@vben/utils": "workspace:*",
+    "@vueuse/core": "catalog:",
+    "aieditor": "^1.3.8",
+    "ant-design-vue": "catalog:",
+    "dayjs": "catalog:",
+    "monaco-editor": "^0.52.2",
+    "pinia": "catalog:",
+    "sm-crypto-v2": "^1.9.2",
+    "vue": "catalog:",
+    "vue-router": "catalog:",
+    "vuedraggable": "^4.1.0"
+  }
+}

+ 108 - 0
apps/baicai-cms/playwright.config.ts

@@ -0,0 +1,108 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+
+import { devices } from '@playwright/test';
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+  expect: {
+    /**
+     * Maximum time expect() should wait for the condition to be met.
+     * For example in `await expect(locator).toHaveText();`
+     */
+    timeout: 5000,
+  },
+  /* Fail the build on CI if you accidentally left test.only in the source code. */
+  forbidOnly: !!process.env.CI,
+  /* Folder for test artifacts such as screenshots, videos, traces, etc. */
+  outputDir: 'node_modules/.e2e/test-results/',
+  /* Configure projects for major browsers */
+  projects: [
+    {
+      name: 'chromium',
+      use: {
+        ...devices['Desktop Chrome'],
+      },
+    },
+    // {
+    //   name: 'firefox',
+    //   use: {
+    //     ...devices['Desktop Firefox'],
+    //   },
+    // },
+    // {
+    //   name: 'webkit',
+    //   use: {
+    //     ...devices['Desktop Safari'],
+    //   },
+    // },
+
+    /* Test against mobile viewports. */
+    // {
+    //   name: 'Mobile Chrome',
+    //   use: {
+    //     ...devices['Pixel 5'],
+    //   },
+    // },
+    // {
+    //   name: 'Mobile Safari',
+    //   use: {
+    //     ...devices['iPhone 12'],
+    //   },
+    // },
+
+    /* Test against branded browsers. */
+    // {
+    //   name: 'Microsoft Edge',
+    //   use: {
+    //     channel: 'msedge',
+    //   },
+    // },
+    // {
+    //   name: 'Google Chrome',
+    //   use: {
+    //     channel: 'chrome',
+    //   },
+    // },
+  ],
+  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+  reporter: [
+    ['list'],
+    ['html', { outputFolder: 'node_modules/.e2e/test-results' }],
+  ],
+  /* Retry on CI only */
+  retries: process.env.CI ? 2 : 0,
+  testDir: './__tests__/e2e',
+  /* Maximum time one test can run for. */
+  timeout: 30 * 1000,
+  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  use: {
+    /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
+    actionTimeout: 0,
+    /* Base URL to use in actions like `await page.goto('/')`. */
+    baseURL: 'http://localhost:5555',
+    /* Only on CI systems run the tests headless */
+    headless: !!process.env.CI,
+    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    trace: 'retain-on-failure',
+  },
+
+  /* Run your local dev server before starting the tests */
+  webServer: {
+    command: process.env.CI ? 'pnpm preview --port 5555' : 'pnpm dev',
+    port: 5555,
+    reuseExistingServer: !process.env.CI,
+  },
+
+  /* Opt out of parallel tests on CI. */
+  workers: process.env.CI ? 1 : undefined,
+};
+
+export default config;

+ 1 - 0
apps/baicai-cms/postcss.config.mjs

@@ -0,0 +1 @@
+export { default } from '@vben/tailwind-config/postcss';

BIN
apps/baicai-cms/public/favicon.ico


+ 211 - 0
apps/baicai-cms/src/adapter/component/index.ts

@@ -0,0 +1,211 @@
+/**
+ * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
+ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
+ */
+
+import type { Component } from 'vue';
+
+import type { BaseFormComponentType } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
+
+import type { CustomComponentType } from '#/components/form/types';
+
+import {
+  defineAsyncComponent,
+  defineComponent,
+  getCurrentInstance,
+  h,
+  ref,
+} from 'vue';
+
+import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import { notification } from 'ant-design-vue';
+
+import { registerComponent } from '#/components/form/component-map';
+
+const AutoComplete = defineAsyncComponent(
+  () => import('ant-design-vue/es/auto-complete'),
+);
+const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
+const Checkbox = defineAsyncComponent(
+  () => import('ant-design-vue/es/checkbox'),
+);
+const CheckboxGroup = defineAsyncComponent(() =>
+  import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
+);
+const DatePicker = defineAsyncComponent(
+  () => import('ant-design-vue/es/date-picker'),
+);
+const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
+const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
+const InputNumber = defineAsyncComponent(
+  () => import('ant-design-vue/es/input-number'),
+);
+const InputPassword = defineAsyncComponent(() =>
+  import('ant-design-vue/es/input').then((res) => res.InputPassword),
+);
+const Mentions = defineAsyncComponent(
+  () => import('ant-design-vue/es/mentions'),
+);
+const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
+const RadioGroup = defineAsyncComponent(() =>
+  import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
+);
+const RangePicker = defineAsyncComponent(() =>
+  import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
+);
+const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
+const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
+const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
+const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
+const Textarea = defineAsyncComponent(() =>
+  import('ant-design-vue/es/input').then((res) => res.Textarea),
+);
+const TimePicker = defineAsyncComponent(
+  () => import('ant-design-vue/es/time-picker'),
+);
+const TreeSelect = defineAsyncComponent(
+  () => import('ant-design-vue/es/tree-select'),
+);
+const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
+
+const withDefaultPlaceholder = <T extends Component>(
+  component: T,
+  type: 'input' | 'select',
+  componentProps: Recordable<any> = {},
+) => {
+  return defineComponent({
+    name: component.name,
+    inheritAttrs: false,
+    setup: (props: any, { attrs, expose, slots }) => {
+      const placeholder =
+        props?.placeholder ||
+        attrs?.placeholder ||
+        $t(`ui.placeholder.${type}`);
+      // 透传组件暴露的方法
+      const innerRef = ref();
+      const publicApi: Recordable<any> = {};
+      expose(publicApi);
+      const instance = getCurrentInstance();
+      instance?.proxy?.$nextTick(() => {
+        for (const key in innerRef.value) {
+          if (typeof innerRef.value[key] === 'function') {
+            publicApi[key] = innerRef.value[key];
+          }
+        }
+      });
+      return () =>
+        h(
+          component,
+          { ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
+          slots,
+        );
+    },
+  });
+};
+
+// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
+export type ComponentType =
+  | 'ApiSelect'
+  | 'ApiTreeSelect'
+  | 'AutoComplete'
+  | 'Checkbox'
+  | 'CheckboxGroup'
+  | 'DatePicker'
+  | 'DefaultButton'
+  | 'Divider'
+  | 'IconPicker'
+  | 'Input'
+  | 'InputNumber'
+  | 'InputPassword'
+  | 'Mentions'
+  | 'PrimaryButton'
+  | 'Radio'
+  | 'RadioGroup'
+  | 'RangePicker'
+  | 'Rate'
+  | 'Select'
+  | 'Space'
+  | 'Switch'
+  | 'Textarea'
+  | 'TimePicker'
+  | 'TreeSelect'
+  | 'Upload'
+  | BaseFormComponentType
+  | CustomComponentType;
+
+async function initComponentAdapter() {
+  const components: Partial<Record<ComponentType, Component>> = {
+    // 如果你的组件体积比较大,可以使用异步加载
+    // Button: () =>
+    // import('xxx').then((res) => res.Button),
+
+    ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
+      component: Select,
+      loadingSlot: 'suffixIcon',
+      modelPropName: 'value',
+      visibleEvent: 'onVisibleChange',
+    }),
+    ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
+      component: TreeSelect,
+      fieldNames: { label: 'label', value: 'value', children: 'children' },
+      loadingSlot: 'suffixIcon',
+      modelPropName: 'value',
+      optionsPropName: 'treeData',
+      visibleEvent: 'onVisibleChange',
+    }),
+    AutoComplete,
+    Checkbox,
+    CheckboxGroup,
+    DatePicker,
+    // 自定义默认按钮
+    DefaultButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'default' }, slots);
+    },
+    Divider,
+    IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
+      iconSlot: 'addonAfter',
+      inputComponent: Input,
+      modelValueProp: 'value',
+    }),
+    Input: withDefaultPlaceholder(Input, 'input'),
+    InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
+    InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
+    Mentions: withDefaultPlaceholder(Mentions, 'input'),
+    // 自定义主要按钮
+    PrimaryButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'primary' }, slots);
+    },
+    Radio,
+    RadioGroup,
+    RangePicker,
+    Rate,
+    Select: withDefaultPlaceholder(Select, 'select'),
+    Space,
+    Switch,
+    Textarea: withDefaultPlaceholder(Textarea, 'input'),
+    TimePicker,
+    TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
+    Upload,
+  };
+  // 自动注册自定义组件
+  registerComponent(components);
+  // 将组件注册到全局共享状态中
+  globalShareState.setComponents(components);
+
+  // 定义全局共享状态中的消息提示
+  globalShareState.defineMessage({
+    // 复制成功消息提示
+    copyPreferencesSuccess: (title, content) => {
+      notification.success({
+        description: content,
+        message: title,
+        placement: 'bottomRight',
+      });
+    },
+  });
+}
+
+export { initComponentAdapter };

+ 47 - 0
apps/baicai-cms/src/adapter/form.ts

@@ -0,0 +1,47 @@
+import type {
+  VbenFormSchema as FormSchema,
+  VbenFormProps,
+} from '@vben/common-ui';
+
+import type { ComponentType } from './component';
+
+import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+const modelPropNameMap: any = {
+  Checkbox: 'checked',
+  Radio: 'checked',
+  Switch: 'checked',
+  Upload: 'fileList',
+};
+
+setupVbenForm<ComponentType>({
+  config: {
+    // ant design vue组件库默认都是 v-model:value
+    baseModelPropName: 'value',
+    // 一些组件是 v-model:checked 或者 v-model:fileList
+    modelPropNameMap,
+  },
+  defineRules: {
+    // 输入项目必填国际化适配
+    required: (value, _params, ctx) => {
+      if (value === undefined || value === null || value.length === 0) {
+        return $t('ui.formRules.required', [ctx.label]);
+      }
+      return true;
+    },
+    // 选择项目必填国际化适配
+    selectRequired: (value, _params, ctx) => {
+      if (value === undefined || value === null) {
+        return $t('ui.formRules.selectRequired', [ctx.label]);
+      }
+      return true;
+    },
+  },
+});
+
+const useVbenForm = useForm<ComponentType>;
+
+export { modelPropNameMap, useVbenForm, z };
+export type VbenFormSchema = FormSchema<ComponentType>;
+export type { VbenFormProps };

+ 40 - 0
apps/baicai-cms/src/adapter/index.ts

@@ -0,0 +1,40 @@
+import { deepMerge } from '#/utils';
+
+export * from './form';
+export * from './vxe-table';
+
+export const useTableGridOptions = (
+  options: Record<string, any>,
+): Record<string, any> => {
+  const defaultOptions = {
+    gridOptions: {
+      toolbarConfig: {
+        refresh: true,
+        print: false,
+        export: false,
+        zoom: true,
+        custom: true,
+      },
+      height: 'auto',
+      keepSource: true,
+    },
+  };
+
+  return deepMerge(defaultOptions, options);
+};
+
+export const useFormOptions = (
+  options: Record<string, any>,
+): Record<string, any> => {
+  const defaultOptions = {
+    showDefaultActions: false,
+    commonConfig: {
+      componentProps: {
+        class: 'w-full',
+      },
+    },
+    wrapperClass: 'grid-cols-1',
+  };
+
+  return deepMerge(defaultOptions, options);
+};

+ 426 - 0
apps/baicai-cms/src/adapter/vxe-table.ts

@@ -0,0 +1,426 @@
+import type { AlertProps } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
+
+import type { ActionItem } from '#/components/table-action';
+
+import { h } from 'vue';
+
+import { confirm, globalShareState } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+import { $te } from '@vben/locales';
+import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
+import { get, isFunction, isString } from '@vben/utils';
+
+import { objectOmit } from '@vueuse/core';
+import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue';
+
+import { TableAction } from '#/components/table-action';
+import { $t } from '#/locales';
+import { deepMerge } from '#/utils';
+
+import { modelPropNameMap, useVbenForm } from './form';
+
+setupVbenVxeTable({
+  configVxeTable: (vxeUI) => {
+    vxeUI.setConfig({
+      grid: {
+        align: 'center',
+        border: true,
+        stripe: true,
+        columnConfig: {
+          resizable: true,
+        },
+        formConfig: {
+          // 全局禁用vxe-table的表单配置,使用formOptions
+          enabled: false,
+        },
+        minHeight: 180,
+        proxyConfig: {
+          autoLoad: true,
+          response: {
+            result: 'items',
+            total: 'total',
+            list: '',
+          },
+          showActiveMsg: true,
+          showResponseMsg: false,
+        },
+        round: true,
+        showOverflow: true,
+        size: 'small',
+      },
+    });
+
+    /**
+     * 解决vxeTable在热更新时可能会出错的问题
+     */
+    vxeUI.renderer.forEach((_item, key) => {
+      if (key.startsWith('Cell')) {
+        vxeUI.renderer.delete(key);
+      }
+    });
+
+    // 表格配置项可以用 cellRender: { name: 'CellImage' },
+    vxeUI.renderer.add('CellImage', {
+      renderTableDefault(_renderOpts, params) {
+        const { column, row } = params;
+        return h(Image, { src: row[column.field] });
+      },
+    });
+
+    // 表格配置项可以用 cellRender: { name: 'CellLink' },
+    vxeUI.renderer.add('CellLink', {
+      renderTableDefault(renderOpts) {
+        const { props } = renderOpts;
+        return h(
+          Button,
+          { size: 'small', type: 'link' },
+          { default: () => props?.text },
+        );
+      },
+    });
+
+    // 单元格渲染: Tag
+    vxeUI.renderer.add('CellTag', {
+      renderTableDefault({ options, props }, { column, row }) {
+        const value = get(row, column.field);
+        const tagOptions = options || [
+          { color: 'success', label: $t('common.enabled'), value: 1 },
+          { color: 'error', label: $t('common.disabled'), value: 2 },
+        ];
+        const tagItem = tagOptions.find((item) => item.value === value);
+        return h(
+          Tag,
+          {
+            ...props,
+            ...objectOmit(tagItem ?? {}, ['label']),
+          },
+          { default: () => tagItem?.label ?? value },
+        );
+      },
+    });
+
+    vxeUI.renderer.add('CellSwitch', {
+      renderTableDefault({ attrs, props }, { column, row }) {
+        const loadingKey = `__loading_${column.field}`;
+        const finallyProps = {
+          checkedChildren: $t('common.enabled'),
+          checkedValue: 1,
+          unCheckedChildren: $t('common.disabled'),
+          unCheckedValue: 0,
+          ...props,
+          checked: row[column.field],
+          loading: row[loadingKey] ?? false,
+          'onUpdate:checked': onChange,
+        };
+        async function onChange(newVal: any) {
+          row[loadingKey] = true;
+          try {
+            const result = await attrs?.beforeChange?.(newVal, row);
+            if (result !== false) {
+              row[column.field] = newVal;
+            }
+          } finally {
+            row[loadingKey] = false;
+          }
+        }
+        return h(Switch, finallyProps);
+      },
+    });
+
+    /**
+     * 注册表格的操作按钮渲染器
+     */
+    vxeUI.renderer.add('CellOperation', {
+      renderTableDefault({ attrs, options, props }, { column, row }) {
+        const defaultProps = { size: 'small', type: 'link', ...props };
+        let align = 'end';
+        switch (column.align) {
+          case 'center': {
+            align = 'center';
+            break;
+          }
+          case 'left': {
+            align = 'start';
+            break;
+          }
+          default: {
+            align = 'end';
+            break;
+          }
+        }
+        const presets: Recordable<Recordable<any>> = {
+          delete: {
+            danger: true,
+            text: $t('common.delete'),
+          },
+          edit: {
+            text: $t('common.edit'),
+          },
+        };
+        const operations: Array<Recordable<any>> = (
+          options || ['edit', 'delete']
+        )
+          .map((opt) => {
+            if (isString(opt)) {
+              return presets[opt]
+                ? { code: opt, ...presets[opt], ...defaultProps }
+                : {
+                    code: opt,
+                    text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
+                    ...defaultProps,
+                  };
+            } else {
+              return { ...defaultProps, ...presets[opt.code], ...opt };
+            }
+          })
+          .map((opt) => {
+            const optBtn: Recordable<any> = {};
+            Object.keys(opt).forEach((key) => {
+              optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
+            });
+            return optBtn;
+          })
+          .filter((opt) => opt.show !== false);
+
+        function renderBtn(opt: Recordable<any>, listen = true) {
+          return h(
+            Button,
+            {
+              ...props,
+              ...opt,
+              icon: undefined,
+              onClick: listen
+                ? () =>
+                    attrs?.onClick?.({
+                      code: opt.code,
+                      row,
+                    })
+                : undefined,
+            },
+            {
+              default: () => {
+                const content = [];
+                if (opt.icon) {
+                  content.push(
+                    h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
+                  );
+                }
+                content.push(opt.text);
+                return content;
+              },
+            },
+          );
+        }
+
+        function renderConfirm(opt: Recordable<any>) {
+          let viewportWrapper: HTMLElement | null = null;
+          return h(
+            Popconfirm,
+            {
+              /**
+               * 当popconfirm用在固定列中时,将固定列作为弹窗的容器时可能会因为固定列较窄而无法容纳弹窗
+               * 将表格主体区域作为弹窗容器时又会因为固定列的层级较高而遮挡弹窗
+               * 将body或者表格视口区域作为弹窗容器时又会导致弹窗无法跟随表格滚动。
+               * 鉴于以上各种情况,一种折中的解决方案是弹出层展示时,禁止操作表格的滚动条。
+               * 这样既解决了弹窗的遮挡问题,又不至于让弹窗随着表格的滚动而跑出视口区域。
+               */
+              getPopupContainer(el) {
+                viewportWrapper = el.closest('.vxe-table--viewport-wrapper');
+                return document.body;
+              },
+              placement: 'topLeft',
+              title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
+              ...props,
+              ...opt,
+              icon: undefined,
+              onOpenChange: (open: boolean) => {
+                // 当弹窗打开时,禁止表格的滚动
+                if (open) {
+                  viewportWrapper?.style.setProperty('pointer-events', 'none');
+                } else {
+                  viewportWrapper?.style.removeProperty('pointer-events');
+                }
+              },
+              onConfirm: () => {
+                attrs?.onClick?.({
+                  code: opt.code,
+                  row,
+                });
+              },
+            },
+            {
+              default: () => renderBtn({ ...opt }, false),
+              description: () =>
+                h(
+                  'div',
+                  { class: 'truncate' },
+                  $t('ui.actionMessage.deleteConfirm', [
+                    row[attrs?.nameField || 'name'],
+                  ]),
+                ),
+            },
+          );
+        }
+
+        const btns = operations.map((opt) =>
+          opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
+        );
+        return h(
+          'div',
+          {
+            class: 'flex table-operations',
+            style: { justifyContent: align },
+          },
+          btns,
+        );
+      },
+    });
+
+    // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
+    // vxeUI.formats.add
+    /**
+     * 注册表格的操作按钮渲染器
+     */
+    vxeUI.renderer.add('CellAction', {
+      renderTableDefault({ attrs, options, props }, { column, row }) {
+        const defaultProps = { size: 'small', type: 'text', ...props };
+        const presets: Recordable<Recordable<any>> = {
+          delete: {
+            danger: true,
+            label: $t('common.delete'),
+            confirm: {
+              title: '删除提示',
+              content: `确定要删除记录 [${row[attrs?.nameField || 'name']}] 吗?`,
+            },
+          },
+          edit: {
+            label: $t('common.edit'),
+          },
+        };
+
+        const actions: ActionItem[] = [];
+        const dropDownActions: ActionItem[] = [];
+
+        (options || ['edit', 'delete']).forEach((opt, index) => {
+          let action: Record<string, any> = {};
+          if (isString(opt)) {
+            action = presets[opt]
+              ? { code: opt, ...presets[opt], ...defaultProps }
+              : {
+                  code: opt,
+                  label: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
+                  ...defaultProps,
+                };
+          } else {
+            action = { ...defaultProps, ...presets[opt.code], ...opt };
+          }
+          const optBtn: ActionItem = {};
+          Object.keys(action).forEach((key) => {
+            optBtn[key] = isFunction(action[key])
+              ? action[key](row)
+              : action[key];
+          });
+          optBtn.onClick = () => {
+            if (action.confirm) {
+              const confirmOptions: AlertProps = deepMerge(
+                {
+                  icon: 'question',
+                  title: '提示',
+                  cancelText: `关闭`,
+                  beforeClose: async ({
+                    isConfirm,
+                  }: {
+                    isConfirm: boolean;
+                  }) => {
+                    if (isConfirm) {
+                      await attrs?.onClick?.({
+                        code: action.code,
+                        row,
+                      });
+                      return true;
+                    }
+                  },
+                },
+                action.confirm,
+              ) as AlertProps;
+              confirm(confirmOptions);
+            } else {
+              attrs?.onClick?.({
+                code: action.code,
+                row,
+              });
+            }
+          };
+          optBtn.authDisabled = true;
+
+          if (index < 2) {
+            actions.push(optBtn);
+          } else {
+            optBtn.type = 'link';
+            dropDownActions.push(optBtn);
+          }
+        });
+        let width = 0;
+        if (actions.length === 1) {
+          width = 60;
+        } else {
+          width = dropDownActions.length > 0 ? 150 : 108;
+        }
+        if (width > Number(column.width || 0)) {
+          column.width = width;
+        }
+        return h(TableAction, {
+          actions,
+          dropDownActions,
+        });
+      },
+    });
+
+    // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
+    // vxeUI.formats.add
+    // 增加编辑组件
+    const components = globalShareState.getComponents();
+    Object.keys(components).forEach((key: any) => {
+      const component = components[key];
+      const modelPropName = modelPropNameMap[key] || 'value';
+      vxeUI.renderer.add(key, {
+        renderTableEdit(renderOpts, params) {
+          const { row, column, $table } = params;
+          return h(component, {
+            ...renderOpts.props,
+            [modelPropName]: row[column.field],
+            [`onUpdate:${modelPropName}`]: (value: any) => {
+              params.row[params.column.field] = value;
+              $table.updateStatus(params);
+            },
+          });
+        },
+        // // 可编辑显示模板
+        // renderTableCell(renderOpts, params) {
+        //   const { props } = renderOpts;
+        //   const comp = (componentMap as any).get(renderOpts.name as any);
+        //   const { column, row } = params;
+        //   const value = get(row, column.field);
+        //   return comp
+        //     ? h(comp, {
+        //         ...props,
+        //         value,
+        //       })
+        //     : value;
+        // },
+      });
+    });
+  },
+  useVbenForm,
+});
+
+export { useVbenVxeGrid };
+export type OnActionClickParams<T = Recordable<any>> = {
+  code: string;
+  row: T;
+};
+export type OnActionClickFn<T = Recordable<any>> = (
+  params: OnActionClickParams<T>,
+) => void;
+export type * from '@vben/plugins/vxe-table';

+ 62 - 0
apps/baicai-cms/src/api/core/auth.ts

@@ -0,0 +1,62 @@
+import { baseRequestClient, requestClient } from '#/api/request';
+import { encrypt } from '#/utils';
+
+export namespace AuthApi {
+  /** 登录接口参数 */
+  export interface LoginParams {
+    password: string;
+    username?: string;
+  }
+
+  /** 登录接口返回值 */
+  export interface LoginResult {
+    accessToken: string;
+  }
+
+  export interface RefreshTokenResult {
+    data: string;
+    status: number;
+  }
+}
+
+/**
+ * 登录
+ */
+export async function loginApi(data: AuthApi.LoginParams) {
+  const postData = {
+    password: encrypt(data.password),
+    username: data.username,
+  };
+  return requestClient.post<AuthApi.LoginResult>('/security/login', postData, {
+    withCredentials: true,
+  });
+}
+
+/**
+ * 刷新accessToken
+ */
+export async function refreshTokenApi() {
+  return baseRequestClient.post<AuthApi.RefreshTokenResult>(
+    '/security/refresh-token',
+    null,
+    {
+      withCredentials: true,
+    },
+  );
+}
+
+/**
+ * 退出登录
+ */
+export async function logoutApi() {
+  return baseRequestClient.post('/security/logout', null, {
+    withCredentials: true,
+  });
+}
+
+/**
+ * 获取用户权限码
+ */
+export async function getAccessCodesApi() {
+  return requestClient.get<string[]>('/security/permission-list');
+}

+ 3 - 0
apps/baicai-cms/src/api/core/index.ts

@@ -0,0 +1,3 @@
+export * from './auth';
+export * from './menu';
+export * from './user';

+ 15 - 0
apps/baicai-cms/src/api/core/menu.ts

@@ -0,0 +1,15 @@
+import type { RouteRecordStringComponent } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+import { useWebStore } from '#/store';
+
+/**
+ * 获取用户所有菜单
+ */
+export async function getAllMenusApi() {
+  const webStore = useWebStore();
+  return requestClient.get<RouteRecordStringComponent[]>(
+    '/frontend/site/menu',
+    { params: { siteId: webStore.config?.id } },
+  );
+}

+ 10 - 0
apps/baicai-cms/src/api/core/user.ts

@@ -0,0 +1,10 @@
+import type { UserInfo } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+/**
+ * 获取用户信息
+ */
+export async function getUserInfoApi() {
+  return requestClient.get<UserInfo>('/security/user-info');
+}

+ 3 - 0
apps/baicai-cms/src/api/examples/index.ts

@@ -0,0 +1,3 @@
+export * from './status';
+export * from './table';
+export * from './upload';

+ 10 - 0
apps/baicai-cms/src/api/examples/status.ts

@@ -0,0 +1,10 @@
+import { requestClient } from '#/api/request';
+
+/**
+ * 模拟任意状态码
+ */
+async function getMockStatusApi(status: string) {
+  return requestClient.get('/status', { params: { status } });
+}
+
+export { getMockStatusApi };

+ 18 - 0
apps/baicai-cms/src/api/examples/table.ts

@@ -0,0 +1,18 @@
+import { requestClient } from '#/api/request';
+
+export namespace DemoTableApi {
+  export interface PageFetchParams {
+    [key: string]: any;
+    page: number;
+    pageSize: number;
+  }
+}
+
+/**
+ * 获取示例表格数据
+ */
+async function getExampleTableApi(params: DemoTableApi.PageFetchParams) {
+  return requestClient.get('/table/list', { params });
+}
+
+export { getExampleTableApi };

+ 25 - 0
apps/baicai-cms/src/api/examples/upload.ts

@@ -0,0 +1,25 @@
+import { requestClient } from '#/api/request';
+
+interface UploadFileParams {
+  file: File;
+  onError?: (error: Error) => void;
+  onProgress?: (progress: { percent: number }) => void;
+  onSuccess?: (data: any, file: File) => void;
+}
+export async function uploadFile({
+  file,
+  onError,
+  onProgress,
+  onSuccess,
+}: UploadFileParams) {
+  try {
+    onProgress?.({ percent: 0 });
+
+    const data = await requestClient.upload('/file/upload', { file });
+
+    onProgress?.({ percent: 100 });
+    onSuccess?.(data, file);
+  } catch (error) {
+    onError?.(error instanceof Error ? error : new Error(String(error)));
+  }
+}

+ 3 - 0
apps/baicai-cms/src/api/index.ts

@@ -0,0 +1,3 @@
+export * from './core';
+export * from './examples';
+export * from './site';

+ 132 - 0
apps/baicai-cms/src/api/model/index.ts

@@ -0,0 +1,132 @@
+export interface BasicPageParams {
+  pageIndex: number;
+  pageSize: number;
+}
+
+export interface BasicFetchResult<T> {
+  items: T[];
+  total: number;
+}
+
+export interface StatusParams {
+  id: number;
+  status: number;
+}
+
+export interface TransferOptionResult {
+  key: string;
+  title: string;
+}
+
+export interface BasicOptionResult {
+  label: string;
+  value: boolean | number | string;
+  disabled?: boolean;
+}
+
+export interface BasicTreeOptionResult extends BasicOptionResult {
+  children: BasicTreeOptionResult[];
+  parentId: number | string;
+}
+
+export interface RelationRequest {
+  id: number;
+  relationIds: number[] | string[];
+}
+
+export interface Condition {
+  fieldName: string;
+  fieldValue: any;
+  conditionalType?: string;
+}
+
+export interface QueryParams {
+  code: string;
+  conditions?: Condition[];
+}
+
+export const statusOptions: BasicOptionResult[] = [
+  { label: '启用', value: 1 },
+  { label: '停用', value: 2 },
+];
+
+export const formatterStatus = ({
+  cellValue,
+}: {
+  cellValue: number | string;
+}) => {
+  const item = statusOptions.find((item) => item.value === cellValue);
+  if (item) return item.label;
+  return '';
+};
+
+export const boolOptions: BasicOptionResult[] = [
+  { label: '是', value: true },
+  { label: '否', value: false },
+];
+
+export const dbTypeOptions: BasicOptionResult[] = [
+  { label: 'MySql', value: 0 },
+  { label: 'SqlServer', value: 1 },
+  { label: 'Sqlite', value: 2 },
+  { label: 'Oracle', value: 3 },
+  { label: 'PostgreSQL', value: 4 },
+  { label: 'Dm', value: 5 },
+  { label: 'Kdbndp', value: 6 },
+  { label: 'Oscar', value: 7 },
+  { label: 'MySqlConnector', value: 8 },
+  { label: 'Access', value: 9 },
+  { label: 'OpenGauss', value: 10 },
+  { label: 'QuestDB', value: 11 },
+  { label: 'HG', value: 12 },
+  { label: 'ClickHouse', value: 13 },
+  { label: 'GBase', value: 14 },
+  { label: 'Custom', value: 15 },
+];
+
+export const connectTypeOptions: BasicOptionResult[] = [
+  { label: '内联接', value: 0 },
+  { label: '左联接', value: 1 },
+  { label: '右联接', value: 2 },
+  { label: '全联接', value: 3 },
+];
+
+export const conditionalTypeOptions: BasicOptionResult[] = [
+  { label: '等于', value: 0 },
+  { label: '包含', value: 1 },
+  { label: '大于', value: 2 },
+  { label: '大于等于', value: 3 },
+  { label: '小于', value: 4 },
+  { label: '小于等于', value: 5 },
+  { label: '介于', value: 6 },
+  { label: '不介于', value: 7 },
+  { label: '开始于', value: 8 },
+  { label: '结束于', value: 9 },
+  { label: '不等于', value: 10 },
+  { label: '不为空', value: 11 },
+
+  // { label: '不包含', value: 7 },
+  // { label: '为空', value: 8 },
+  // { label: '正则匹配', value: 14 },
+  // { label: '不正则匹配', value: 15 },
+];
+
+export const dataTypeOptions: BasicOptionResult[] = [
+  { label: 'text', value: 'text' },
+  { label: 'varchar', value: 'varchar' },
+  { label: 'nvarchar', value: 'nvarchar' },
+  { label: 'char', value: 'char' },
+  { label: 'nchar', value: 'nchar' },
+  { label: 'timestamp', value: 'timestamp' },
+  { label: 'int', value: 'int' },
+  { label: 'smallint', value: 'smallint' },
+  { label: 'tinyint', value: 'tinyint' },
+  { label: 'bigint', value: 'bigint' },
+  { label: 'bit', value: 'bit' },
+  { label: 'decimal', value: 'decimal' },
+  { label: 'datetime', value: 'datetime' },
+  { label: 'date', value: 'date' },
+  { label: 'blob', value: 'blob' },
+  { label: 'clob', value: 'clob' },
+  { label: 'boolean', value: 'boolean' },
+];

+ 124 - 0
apps/baicai-cms/src/api/request.ts

@@ -0,0 +1,124 @@
+/**
+ * 该文件可自行根据业务逻辑进行调整
+ */
+import type { RequestClientOptions } from '@vben/request';
+
+import { useAppConfig } from '@vben/hooks';
+import { preferences } from '@vben/preferences';
+import {
+  authenticateResponseInterceptor,
+  defaultResponseInterceptor,
+  errorMessageResponseInterceptor,
+  RequestClient,
+} from '@vben/request';
+import { useAccessStore } from '@vben/stores';
+
+import { message } from 'ant-design-vue';
+
+import { useAuthStore } from '#/store';
+
+import { refreshTokenApi } from './core';
+
+const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
+
+function createRequestClient(baseURL: string, options?: RequestClientOptions) {
+  const client = new RequestClient({
+    ...options,
+    baseURL,
+  });
+
+  /**
+   * 重新认证逻辑
+   */
+  async function doReAuthenticate() {
+    console.warn('Access token or refresh token is invalid or expired. ');
+    const accessStore = useAccessStore();
+    const authStore = useAuthStore();
+    accessStore.setAccessToken(null);
+    if (
+      preferences.app.loginExpiredMode === 'modal' &&
+      accessStore.isAccessChecked
+    ) {
+      accessStore.setLoginExpired(true);
+    } else {
+      await authStore.logout();
+    }
+  }
+
+  /**
+   * 刷新token逻辑
+   */
+  async function doRefreshToken() {
+    const accessStore = useAccessStore();
+    const resp = await refreshTokenApi();
+    const newToken = resp.data;
+    accessStore.setAccessToken(newToken);
+    return newToken;
+  }
+
+  function formatToken(token: null | string) {
+    return token ? `Bearer ${token}` : null;
+  }
+
+  // 请求头处理
+  client.addRequestInterceptor({
+    fulfilled: async (config) => {
+      const accessStore = useAccessStore();
+
+      config.headers.Authorization = formatToken(accessStore.accessToken);
+      config.headers['Accept-Language'] = preferences.app.locale;
+      return config;
+    },
+  });
+
+  // 处理返回的响应数据格式
+  client.addResponseInterceptor(
+    defaultResponseInterceptor({
+      codeField: 'code',
+      dataField: 'data',
+      successCode: 200,
+    }),
+  );
+
+  // token过期的处理
+  client.addResponseInterceptor(
+    authenticateResponseInterceptor({
+      client,
+      doReAuthenticate,
+      doRefreshToken,
+      enableRefreshToken: preferences.app.enableRefreshToken,
+      formatToken,
+    }),
+  );
+
+  // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
+  client.addResponseInterceptor(
+    errorMessageResponseInterceptor((msg: string, error) => {
+      // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
+      // 当前mock接口返回的错误字段是 error 或者 message
+      const responseData = error?.response?.data ?? {};
+      if (responseData?.code === 401) {
+        message.error('登录已过期,请重新登录');
+        doReAuthenticate();
+      } else {
+        const errorMessage = responseData?.error ?? responseData?.message ?? '';
+        // 如果没有错误信息,则会根据状态码进行提示
+        message.error(errorMessage || msg);
+      }
+    }),
+  );
+
+  return client;
+}
+
+export const requestClient = createRequestClient(apiURL, {
+  responseReturn: 'data',
+});
+
+export const baseRequestClient = new RequestClient({ baseURL: apiURL });
+
+export interface PageFetchParams {
+  [key: string]: any;
+  pageNo?: number;
+  pageSize?: number;
+}

+ 1 - 0
apps/baicai-cms/src/api/site/index.ts

@@ -0,0 +1 @@
+export * from './site';

+ 17 - 0
apps/baicai-cms/src/api/site/site.ts

@@ -0,0 +1,17 @@
+import { requestClient } from '#/api/request';
+
+export namespace SiteApi {
+  export interface SiteParams {
+    lang: string;
+  }
+
+  export interface SiteRecordItem {
+    id: number;
+    code: string;
+    path: string;
+    status: number;
+  }
+
+  export const getConfig = (params: SiteParams) =>
+    requestClient.get<SiteRecordItem>('/frontend/site/config', { params });
+}

+ 39 - 0
apps/baicai-cms/src/app.vue

@@ -0,0 +1,39 @@
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+import { useAntdDesignTokens } from '@vben/hooks';
+import { preferences, usePreferences } from '@vben/preferences';
+
+import { App, ConfigProvider, theme } from 'ant-design-vue';
+
+import { antdLocale } from '#/locales';
+
+defineOptions({ name: 'App' });
+
+const { isDark } = usePreferences();
+const { tokens } = useAntdDesignTokens();
+
+const tokenTheme = computed(() => {
+  const algorithm = isDark.value
+    ? [theme.darkAlgorithm]
+    : [theme.defaultAlgorithm];
+
+  // antd 紧凑模式算法
+  if (preferences.app.compact) {
+    algorithm.push(theme.compactAlgorithm);
+  }
+
+  return {
+    algorithm,
+    token: tokens,
+  };
+});
+</script>
+
+<template>
+  <ConfigProvider :locale="antdLocale" :theme="tokenTheme">
+    <App>
+      <RouterView />
+    </App>
+  </ConfigProvider>
+</template>

+ 76 - 0
apps/baicai-cms/src/bootstrap.ts

@@ -0,0 +1,76 @@
+import { createApp, watchEffect } from 'vue';
+
+import { registerAccessDirective } from '@vben/access';
+import { registerLoadingDirective } from '@vben/common-ui';
+import { preferences } from '@vben/preferences';
+import { initStores } from '@vben/stores';
+import '@vben/styles';
+import '@vben/styles/antd';
+
+import { useTitle } from '@vueuse/core';
+
+import { $t, setupI18n } from '#/locales';
+import { router } from '#/router';
+
+import { initComponentAdapter } from './adapter/component';
+import App from './app.vue';
+
+async function bootstrap(namespace: string) {
+  // 初始化组件适配器
+  await initComponentAdapter();
+
+  // 设置弹窗的默认配置
+  // setDefaultModalProps({
+  //   fullscreenButton: false,
+  // });
+  // 设置抽屉的默认配置
+  // setDefaultDrawerProps({
+  //   zIndex: 1020,
+  // });
+
+  const app = createApp(App);
+
+  // 注册v-loading指令
+  registerLoadingDirective(app, {
+    loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
+    spinning: 'spinning',
+  });
+
+  // 国际化 i18n 配置
+  await setupI18n(app);
+
+  // 配置 pinia-tore
+  await initStores(app, { namespace });
+
+  // 安装权限指令
+  registerAccessDirective(app);
+
+  // 初始化 tippy
+  const { initTippy } = await import('@vben/common-ui/es/tippy');
+  initTippy(app);
+
+  // 配置路由及路由守卫
+  app.use(router);
+
+  // 配置@tanstack/vue-query
+  const { VueQueryPlugin } = await import('@tanstack/vue-query');
+  app.use(VueQueryPlugin);
+
+  // 配置Motion插件
+  const { MotionPlugin } = await import('@vben/plugins/motion');
+  app.use(MotionPlugin);
+
+  // 动态更新标题
+  watchEffect(() => {
+    if (preferences.app.dynamicTitle) {
+      const routeTitle = router.currentRoute.value.meta?.title;
+      const pageTitle =
+        (routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
+      useTitle(pageTitle);
+    }
+  });
+
+  app.mount('#app');
+}
+
+export { bootstrap };

+ 1 - 0
apps/baicai-cms/src/components/bc-tree/index.ts

@@ -0,0 +1 @@
+export { default as BcTree } from './src/bc-tree.vue';

+ 428 - 0
apps/baicai-cms/src/components/bc-tree/src/bc-tree.vue

@@ -0,0 +1,428 @@
+<script lang="ts" setup>
+import type { TreeProps } from 'ant-design-vue';
+import type { Key } from 'ant-design-vue/es/_util/type';
+import type { TreeDataItem } from 'ant-design-vue/es/tree';
+
+import type { PropType, StyleValue } from 'vue';
+
+import type { ApiConfig, FieldNames } from '#/components/form/types';
+
+import { computed, reactive, ref, toRaw, unref, watch, watchEffect } from 'vue';
+
+import { Loading, VbenScrollbar } from '@vben/common-ui';
+import { EmptyIcon } from '@vben/icons';
+import { isFunction } from '@vben/utils';
+
+import { DirectoryTree, Dropdown, Input, Menu } from 'ant-design-vue';
+
+import { createApiFunction } from '#/components/form/helper';
+import { Icon } from '#/components/icon';
+import { filter, get, treeToList } from '#/utils';
+
+import { ToolbarEnum } from './types';
+import { useTree } from './useTree';
+
+defineOptions({
+  name: 'BcTree',
+});
+
+const props = defineProps({
+  multiple: {
+    type: Boolean as PropType<boolean>,
+    default: true,
+  },
+  title: {
+    type: String as PropType<string>,
+    default: '',
+  },
+  api: {
+    type: Object as PropType<ApiConfig>,
+    default: () => ({
+      type: 'none',
+      method: 'get',
+      params: {},
+      result: '',
+      url: null,
+    }),
+  },
+  fieldNames: {
+    type: Object as PropType<FieldNames>,
+    default: () => ({ label: 'label', value: 'value', children: 'children' }),
+  },
+  expandOnSearch: {
+    type: Boolean,
+    default: true,
+  },
+  immediate: {
+    type: Boolean,
+    default: true,
+  },
+  search: {
+    type: Boolean,
+    default: true,
+  },
+  toolbar: {
+    type: Boolean,
+    default: true,
+  },
+  checkable: {
+    type: Boolean,
+    default: false,
+  },
+  checkedKeys: {
+    type: Array as PropType<Key[]>,
+    default: () => [],
+  },
+  checkStrictly: {
+    type: Boolean,
+    default: false,
+  },
+  treeData: {
+    type: Array as PropType<TreeDataItem[]>,
+    default: null,
+  },
+});
+const emit = defineEmits([
+  'update:searchValue',
+  'change',
+  'update:value',
+  'check',
+]);
+
+const state = reactive<{
+  autoExpandParent: boolean;
+  checkedKeys: (number | string)[];
+  checkStrictly: boolean;
+  expandedKeys: (number | string)[];
+  lastLeafKeys: (number | string)[];
+}>({
+  expandedKeys: [],
+  autoExpandParent: true,
+  checkedKeys: [],
+  lastLeafKeys: [],
+  checkStrictly: props.checkStrictly,
+});
+
+const toolbarList = computed(() => {
+  const { checkable } = props;
+  const defaultToolbarList = [
+    { label: '刷新数据', value: ToolbarEnum.REFRESH, divider: checkable },
+    { label: '展开全部', value: ToolbarEnum.EXPAND_ALL },
+    {
+      label: '折叠全部',
+      value: ToolbarEnum.UN_EXPAND_ALL,
+      divider: checkable,
+    },
+  ];
+
+  return checkable
+    ? [
+        ...defaultToolbarList,
+        { label: '选择全部', value: ToolbarEnum.SELECT_ALL },
+        {
+          label: '取消选择',
+          value: ToolbarEnum.UN_SELECT_ALL,
+          divider: checkable,
+        },
+        // { label: '层级关联', value: ToolbarEnum.CHECK_STRICTLY },
+        // { label: '层级独立', value: ToolbarEnum.CHECK_UN_STRICTLY },
+      ]
+    : defaultToolbarList;
+});
+
+const searchListData = ref('');
+const searchState = reactive({
+  startSearch: false,
+  searchText: '',
+  searchData: [] as TreeDataItem[],
+});
+
+const treeDataRef = ref<TreeDataItem[]>([]);
+
+const loading = ref(false);
+const isFirstLoad = ref(true);
+
+const getFieldNames = computed((): Required<FieldNames> => {
+  const { fieldNames } = props;
+  return {
+    children: 'children',
+    ...fieldNames,
+  };
+});
+
+const { getAllKeys, getLeafNodeIds, getEnabledKeys } = useTree(
+  treeDataRef,
+  getFieldNames,
+);
+const handleExpand = (keys: Key[]) => {
+  state.expandedKeys = keys;
+  state.autoExpandParent = false;
+};
+
+const handleSelect = (keys: Key[], e: any) => {
+  emit('change', keys, e);
+};
+
+const handleCheck = (keys: any, info: any) => {
+  let currentValue = [...keys];
+  if (searchState.startSearch) {
+    currentValue = currentValue.filter((item: number | string) => {
+      return state.lastLeafKeys.includes(item as never);
+    });
+  }
+
+  state.checkedKeys = currentValue;
+
+  const rawVal = toRaw(state.checkedKeys);
+  emit('update:value', rawVal);
+  emit('check', rawVal, info);
+};
+
+const contentStyle = computed<StyleValue>(() => {
+  let height = 45;
+  if (props.search) {
+    height += 45;
+  }
+  return { height: `calc(100% - ${height}px)` };
+});
+const fetch = async () => {
+  const api = createApiFunction({ ...props.api });
+  if (!api || !isFunction(api)) return;
+  try {
+    loading.value = true;
+    const res = await api(props.api.params);
+    if (Array.isArray(res)) {
+      treeDataRef.value = res;
+    } else {
+      treeDataRef.value = props.api.result ? get(res, props.api.result) : [];
+    }
+    state.lastLeafKeys = getLeafNodeIds();
+  } catch (error) {
+    console.warn(error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+watchEffect(() => {
+  props.immediate && fetch();
+});
+
+watchEffect(() => {
+  state.checkStrictly = props.checkStrictly;
+});
+
+watchEffect(() => {
+  state.checkedKeys = props.checkedKeys;
+});
+
+watchEffect(() => {
+  if (props.treeData) {
+    treeDataRef.value = props.treeData as TreeDataItem[];
+  }
+});
+
+watch(
+  () => props.api.params,
+  () => {
+    !unref(isFirstLoad) && fetch();
+  },
+  { deep: true },
+);
+
+watch(
+  () => props.treeData,
+  (val) => {
+    if (val) {
+      handleSearch(searchState.searchText);
+    }
+  },
+);
+
+const setExpandedKeys = (keys: (number | string)[]) => {
+  state.expandedKeys = keys;
+};
+
+// const getExpandedKeys = () => {
+//   return state.expandedKeys;
+// };
+
+const handleSearch = (searchValue: string) => {
+  if (searchValue !== searchState.searchText)
+    searchState.searchText = searchValue;
+  emit('update:searchValue', searchValue);
+  if (!searchValue) {
+    searchState.startSearch = false;
+    return;
+  }
+
+  searchState.startSearch = true;
+  const { fieldNames, expandOnSearch } = props;
+
+  const matchedKeys: string[] = [];
+  searchState.searchData = filter(
+    unref(treeDataRef) as any[],
+    (node) => {
+      const result = node[fieldNames.label]?.includes(searchValue) ?? false;
+      if (result) {
+        matchedKeys.push(node[fieldNames.value]);
+      }
+      return result;
+    },
+    fieldNames,
+  );
+
+  if (expandOnSearch) {
+    const expandKeys = treeToList(searchState.searchData).map((val: any) => {
+      return val[fieldNames.value];
+    });
+    if (expandKeys && expandKeys.length > 0) {
+      setExpandedKeys(expandKeys);
+    }
+  }
+
+  // if (checkOnSearch && checkable && matchedKeys.length > 0) {
+  //   setCheckedKeys(matchedKeys);
+  // }
+
+  // if (selectedOnSearch && matchedKeys.length > 0) {
+  //   setSelectedKeys(matchedKeys);
+  // }
+};
+function handleKeyChange(v: any) {
+  handleSearch(v.target.value);
+}
+
+const getTreeData = computed((): TreeProps['treeData'] =>
+  searchState.startSearch ? searchState.searchData : unref(treeDataRef),
+);
+
+const getNotFound = computed((): boolean => {
+  return !getTreeData.value || getTreeData.value.length === 0;
+});
+
+const expandAll = (expandAll: boolean) => {
+  state.expandedKeys = expandAll ? getAllKeys() : ([] as KeyType[]);
+};
+
+const checkAll = (checkAll: boolean) => {
+  state.checkedKeys = checkAll ? getEnabledKeys() : ([] as KeyType[]);
+};
+const handleMenuClick = async (e: any) => {
+  const { key } = e;
+  switch (key) {
+    case ToolbarEnum.CHECK_STRICTLY: {
+      state.checkStrictly = false;
+      break;
+    }
+    case ToolbarEnum.CHECK_UN_STRICTLY: {
+      state.checkStrictly = true;
+      break;
+    }
+    case ToolbarEnum.EXPAND_ALL: {
+      expandAll(true);
+      break;
+    }
+    case ToolbarEnum.REFRESH: {
+      await fetch();
+      break;
+    }
+    case ToolbarEnum.SELECT_ALL: {
+      checkAll(true);
+      break;
+    }
+    case ToolbarEnum.UN_EXPAND_ALL: {
+      expandAll(false);
+      break;
+    }
+    case ToolbarEnum.UN_SELECT_ALL: {
+      checkAll(false);
+      break;
+    }
+  }
+};
+</script>
+<template>
+  <div class="h-full">
+    <div
+      class="flex h-[45px] items-center justify-between border-b px-2 shadow"
+    >
+      <h1 class="text-xl font-bold">{{ title }}</h1>
+      <div class="cursor-pointer">
+        <Dropdown @click.prevent>
+          <Icon icon="ion:ellipsis-vertical" />
+          <template #overlay>
+            <Menu @click="handleMenuClick">
+              <template v-for="item in toolbarList" :key="item.value">
+                <Menu.Item v-bind="{ key: item.value }">
+                  {{ item.label }}
+                </Menu.Item>
+                <Menu.Divider v-if="item.divider" />
+              </template>
+            </Menu>
+          </template>
+        </Dropdown>
+      </div>
+    </div>
+    <div class="flex h-[45px] items-center px-2" v-if="search">
+      <Input
+        v-model:value="searchListData"
+        @change="handleKeyChange"
+        placeholder="输入关键字搜索"
+      />
+    </div>
+    <VbenScrollbar :style="contentStyle">
+      <Loading :spinning="loading" text="正在加载...">
+        <DirectoryTree
+          :expanded-keys="state.expandedKeys"
+          :auto-expand-parent="state.autoExpandParent"
+          :checked-keys="state.checkedKeys"
+          :tree-data="getTreeData"
+          :field-names="{
+            title: fieldNames.label,
+            key: fieldNames.value,
+            children: fieldNames.children,
+          }"
+          block-node
+          :show-icon="false"
+          class="px-2"
+          :checkable="checkable"
+          @expand="handleExpand"
+          @select="handleSelect"
+          @check="handleCheck"
+          :check-strictly="state.checkStrictly"
+        >
+          <template #title="treeDataItem">
+            <slot name="title" v-bind="treeDataItem">
+              <span
+                v-if="treeDataItem[fieldNames.label].includes(searchListData)"
+              >
+                {{
+                  treeDataItem[fieldNames.label].substring(
+                    0,
+                    treeDataItem[fieldNames.label].indexOf(searchListData),
+                  )
+                }}
+                <span style="color: #f50">{{ searchListData }}</span>
+                {{
+                  treeDataItem[fieldNames.label].substring(
+                    treeDataItem[fieldNames.label].indexOf(searchListData) +
+                      searchListData.length,
+                  )
+                }}
+              </span>
+              <span v-else>{{ treeDataItem[fieldNames.label] }}</span>
+            </slot>
+          </template>
+        </DirectoryTree>
+        <div
+          v-if="getNotFound"
+          class="flex-col-center text-muted-foreground min-h-[150px] w-full"
+        >
+          <EmptyIcon class="size-10" />
+          <div class="mt-1 text-sm">暂无数据</div>
+        </div>
+      </Loading>
+    </VbenScrollbar>
+  </div>
+</template>

+ 9 - 0
apps/baicai-cms/src/components/bc-tree/src/types.ts

@@ -0,0 +1,9 @@
+export enum ToolbarEnum {
+  SELECT_ALL,
+  UN_SELECT_ALL,
+  EXPAND_ALL,
+  UN_EXPAND_ALL,
+  CHECK_STRICTLY,
+  CHECK_UN_STRICTLY,
+  REFRESH,
+}

+ 101 - 0
apps/baicai-cms/src/components/bc-tree/src/useTree.ts

@@ -0,0 +1,101 @@
+import type { TreeDataItem } from 'ant-design-vue/es/tree';
+
+import type { ComputedRef, Ref } from 'vue';
+
+import type { FieldNames } from '#/components/form/types';
+
+import { unref } from 'vue';
+
+export function useTree(
+  treeDataRef: Ref<TreeDataItem[]>,
+  getFieldNames: ComputedRef<FieldNames>,
+) {
+  function getAllKeys(list?: TreeDataItem[]) {
+    const keys: string[] = [];
+    const treeData = list || unref(treeDataRef);
+    const { value: keyField, children: childrenField } = unref(getFieldNames);
+    if (!childrenField || !keyField) return keys;
+
+    for (const node of treeData) {
+      keys.push(node[keyField]);
+      const children = node[childrenField];
+      if (children && children.length > 0) {
+        keys.push(...(getAllKeys(children) as string[]));
+      }
+    }
+    return keys as KeyType[];
+  }
+
+  function getChildrenKeys(nodeKey: number | string, list?: TreeDataItem[]) {
+    const keys: (number | string)[] = [];
+    const treeData = list || unref(treeDataRef);
+    const { value: keyField, children: childrenField } = unref(getFieldNames);
+    if (!childrenField || !keyField) return keys;
+    for (const node of treeData) {
+      const children = node[childrenField];
+      if (nodeKey === node[keyField]) {
+        keys.push(node[keyField]);
+        if (children && children.length > 0) {
+          keys.push(...(getAllKeys(children) as string[]));
+        }
+      } else {
+        if (children && children.length > 0) {
+          keys.push(...getChildrenKeys(nodeKey, children));
+        }
+      }
+    }
+    return keys as (number | string)[];
+  }
+
+  /**
+   * 获取所有叶子节点
+   * @param list
+   */
+  function getLeafNodeIds(list?: TreeDataItem[]) {
+    const treeData = list || unref(treeDataRef);
+    const leafNodeIds: any = [];
+
+    function traverse(node: any) {
+      const { value: keyField, children: childrenField } = unref(getFieldNames);
+      if (
+        !node[childrenField || 'children'] ||
+        node[childrenField || 'children'].length === 0
+      ) {
+        leafNodeIds.push(node[keyField]);
+      } else {
+        for (const child of node[childrenField || 'children']) {
+          traverse(child);
+        }
+      }
+    }
+    for (const root of treeData) {
+      traverse(root);
+    }
+    return leafNodeIds;
+  }
+
+  function getEnabledKeys(list?: TreeDataItem[]) {
+    const keys: string[] = [];
+    const treeData = list || unref(treeDataRef);
+    const { value: keyField, children: childrenField } = unref(getFieldNames);
+    if (!childrenField || !keyField) return keys;
+
+    for (const node of treeData) {
+      node.disabled !== true &&
+        node.selectable !== false &&
+        keys.push(node[keyField]);
+      const children = node[childrenField];
+      if (children && children.length > 0) {
+        keys.push(...(getEnabledKeys(children) as string[]));
+      }
+    }
+    return keys as KeyType[];
+  }
+
+  return {
+    getAllKeys,
+    getChildrenKeys,
+    getLeafNodeIds,
+    getEnabledKeys,
+  };
+}

+ 32 - 0
apps/baicai-cms/src/components/form/component-map.ts

@@ -0,0 +1,32 @@
+import type { Component } from 'vue';
+
+import type { CustomComponentType } from './types';
+
+import { getFileNameWithoutExtension, toPascalCase } from '#/utils';
+
+const componentMap = new Map<CustomComponentType | string, Component>();
+// import.meta.glob() 直接引入所有的模块 Vite 独有的功能
+const modules = import.meta.glob(
+  ['./components/**/*.vue', '../../views/**/components/form/*.vue'],
+  { eager: true },
+);
+// 加入到路由集合中
+Object.keys(modules).forEach((key) => {
+  if (!key.includes('-ignore')) {
+    const component = (modules as any)[key].default || {};
+    const compName = getFileNameWithoutExtension(key);
+    componentMap.set(toPascalCase(compName), component);
+  }
+});
+
+/**
+ * 注册组件
+ * @param components
+ */
+export const registerComponent = (components: any) => {
+  componentMap.forEach((value, key) => {
+    components[key] = value as Component;
+  });
+};
+
+export { componentMap };

+ 120 - 0
apps/baicai-cms/src/components/form/components/bc-checkbox.vue

@@ -0,0 +1,120 @@
+<script setup lang="ts">
+import type { CheckboxValueType } from 'ant-design-vue/es/checkbox/interface';
+
+import type { PropType } from 'vue';
+
+import type { ApiConfig } from '../types';
+
+import type { BasicOptionResult } from '#/api/model';
+
+import { computed, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { CheckboxGroup, Spin } from 'ant-design-vue';
+
+import { get, omit } from '#/utils';
+
+import { createApiFunction } from '../helper';
+
+const props = defineProps({
+  value: {
+    type: [Array] as PropType<CheckboxValueType[]>,
+    default: undefined,
+  },
+  numberToString: {
+    type: Boolean,
+    default: false,
+  },
+  api: {
+    type: Object as PropType<ApiConfig>,
+    default: () => ({
+      type: 'none',
+      method: 'get',
+      params: {},
+      result: '',
+      url: null,
+    }),
+  },
+  fieldNames: {
+    type: Object as PropType<{ label: string; options: any; value: string }>,
+    default: () => ({ label: 'label', value: 'value', options: null }),
+  },
+  immediate: {
+    type: Boolean,
+    default: true,
+  },
+});
+const emit = defineEmits(['update:value', 'optionsChange']);
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+const options = ref<BasicOptionResult[]>([]);
+const loading = ref(false);
+const isFirstLoad = ref(true);
+const getOptions = computed(() => {
+  const { fieldNames, numberToString } = props;
+  const res: BasicOptionResult[] = [];
+  unref(options).forEach((item: any) => {
+    const value = item[fieldNames.value];
+    res.push({
+      ...omit(item, [fieldNames.label, fieldNames.value]),
+      label: item[fieldNames.label],
+      value: numberToString ? `${value}` : value,
+      disabled: item.disabled || false,
+    });
+  });
+  return res;
+});
+
+const emitChange = () => {
+  emit('optionsChange', unref(getOptions));
+};
+
+const fetch = async () => {
+  const api = createApiFunction({ ...props.api });
+  if (!api || !isFunction(api)) return;
+  try {
+    loading.value = true;
+    const res = await api(props.api.params);
+    if (Array.isArray(res)) {
+      options.value = res;
+    } else {
+      options.value = props.api.result ? get(res, props.api.result) : [];
+    }
+    emitChange();
+  } catch (error) {
+    console.warn(error);
+  } finally {
+    loading.value = false;
+  }
+};
+watchEffect(() => {
+  props.immediate && fetch();
+});
+
+watch(
+  () => props.api.params,
+  () => {
+    !unref(isFirstLoad) && fetch();
+  },
+  { deep: true },
+);
+</script>
+
+<template>
+  <Spin :spinning="loading" style="margin-left: 20px">
+    <CheckboxGroup
+      v-bind="$attrs"
+      v-model:value="modelValue"
+      :options="getOptions"
+      class="w-full"
+    >
+      <template v-for="item in Object.keys($slots)" #[item]="data">
+        <slot :name="item" v-bind="data || {}"></slot>
+      </template>
+    </CheckboxGroup>
+  </Spin>
+</template>

+ 192 - 0
apps/baicai-cms/src/components/form/components/bc-editor/bc-editor.vue

@@ -0,0 +1,192 @@
+<script lang="ts" setup>
+import type { PropType } from 'vue';
+
+import { computed, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue';
+
+import { usePreferences } from '@vben/preferences';
+import { isNumber } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { AiEditor } from 'aieditor';
+
+import { uploadFile } from '#/api';
+
+import 'aieditor/dist/style.css';
+
+defineOptions({
+  name: 'BcEditor',
+});
+
+const props = defineProps({
+  value: {
+    type: String,
+    default: () => {
+      return '';
+    },
+  },
+  disabled: {
+    type: Boolean,
+    default: false,
+  },
+  height: {
+    type: [Number, String] as PropType<number | string>,
+    required: false,
+    default: 600,
+  },
+  width: {
+    type: [Number, String] as PropType<number | string>,
+    required: false,
+    default: '100%',
+  },
+  placeholder: {
+    type: String,
+    default: () => {
+      return '';
+    },
+  },
+});
+
+const emit = defineEmits(['update:value']);
+const editorRef = ref<Element | null>(null);
+let aiEditor: AiEditor | null = null;
+
+const { isDark, locale } = usePreferences();
+
+const langName = computed(() => {
+  const lang = locale.value;
+  return ['en', 'zh_CN'].includes(lang) ? lang : 'zh_CN';
+});
+
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+const uploader = (file: File): Promise<Record<string, any>> => {
+  return new Promise((resolve, reject) => {
+    uploadFile({ file, onSuccess: resolve, onError: reject });
+  });
+};
+
+const ininEditor = () => {
+  aiEditor = new AiEditor({
+    element: editorRef.value as Element,
+    placeholder: props.placeholder,
+    content: modelValue.value,
+    theme: isDark.value ? 'dark' : 'light',
+    lang: langName.value,
+    editable: !props.disabled,
+    onChange: (_aiEditor) => {
+      modelValue.value = _aiEditor.getHtml();
+    },
+    image: {
+      uploader,
+      uploaderEvent: {
+        onSuccess: (file: File, response: any) => {
+          return {
+            errorCode: 0,
+            data: {
+              src: response.url,
+              alt: file.name,
+            },
+          };
+        },
+      },
+    },
+    video: {
+      uploader,
+      uploaderEvent: {
+        onSuccess: (_, response: any) => {
+          return {
+            errorCode: 0,
+            data: {
+              src: response.url,
+              poster: '',
+            },
+          };
+        },
+      },
+    },
+    attachment: {
+      uploader,
+      uploaderEvent: {
+        onSuccess: (file: File, response: any) => {
+          return {
+            errorCode: 0,
+            data: {
+              href: response.url,
+              fileName: file.name,
+            },
+          };
+        },
+      },
+    },
+  });
+};
+
+watchEffect(() => {
+  const theme = isDark.value ? 'dark' : 'light';
+  aiEditor?.changeTheme(theme);
+});
+
+watchEffect(() => {
+  aiEditor?.changeLang(locale.value);
+});
+
+onUnmounted(() => {
+  aiEditor && aiEditor.destroy();
+});
+
+onMounted(() => {
+  ininEditor();
+});
+
+watch(
+  () => props.disabled,
+  (newValue) => {
+    aiEditor?.setEditable(!newValue);
+  },
+  { immediate: true },
+);
+watch(
+  () => modelValue.value,
+  (newValue) => {
+    if (newValue !== aiEditor?.getHtml()) {
+      aiEditor?.setContent(newValue);
+    }
+  },
+);
+
+const containerWidth = computed(() => {
+  const width = props.width;
+  if (isNumber(width)) {
+    return `${width}px`;
+  }
+  return width;
+});
+const containerHeight = computed(() => {
+  const height = props.height;
+  if (isNumber(height)) {
+    return `${height}px`;
+  }
+  return height;
+});
+</script>
+
+<template>
+  <div
+    ref="editorRef"
+    :style="{ width: containerWidth, height: containerHeight }"
+  ></div>
+</template>
+
+<style lang="less">
+.tinymce-container {
+  position: relative;
+  height: 100%;
+  overflow: hidden;
+}
+
+.tox-tinymce-aux {
+  z-index: 9999 !important;
+}
+</style>

+ 94 - 0
apps/baicai-cms/src/components/form/components/bc-icon-picker.vue

@@ -0,0 +1,94 @@
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+
+import { IconPicker } from '@vben/common-ui';
+import { listIcons } from '@vben/icons';
+
+import { useVModel } from '@vueuse/core';
+import { Input } from 'ant-design-vue';
+
+import iconData from '#/components/icon/icon.data';
+
+export interface Props {
+  allowClear?: boolean;
+  pageSize?: number;
+  /**
+   * 可以通过prefix获取系统中使用的图标集
+   */
+  prefix?: string;
+  readonly?: boolean;
+  value?: string;
+  width?: string;
+}
+
+// Don't inherit FormItem disabled、placeholder...
+defineOptions({
+  inheritAttrs: false,
+});
+
+const props = withDefaults(defineProps<Props>(), {
+  allowClear: true,
+  pageSize: 36,
+  prefix: '',
+  readonly: false,
+  value: '',
+  width: '100%',
+});
+
+const emit = defineEmits(['update:value']);
+
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+
+const refIconPicker = ref();
+
+const currentList = computed(() => {
+  try {
+    if (props.prefix) {
+      const icons = listIcons('', props.prefix);
+      if (icons.length === 0) {
+        console.warn(`No icons found for prefix: ${props.prefix}`);
+      }
+      return icons;
+    } else {
+      const prefix = iconData.prefix;
+      return iconData.icons.map((icon: any) => `${prefix}:${icon}`);
+    }
+  } catch (error) {
+    console.error('Failed to load icons:', error);
+    return [];
+  }
+});
+
+const triggerPopover = () => {
+  refIconPicker.value?.changeOpenState?.();
+};
+
+const handleChange = (icon: string) => {
+  emit('update:value', icon);
+};
+</script>
+
+<template>
+  <Input
+    v-model:value="modelValue"
+    :allow-clear="props.allowClear"
+    :readonly="props.readonly"
+    :style="{ width }"
+    class="cursor-pointer"
+    placeholder="点击选中图标"
+    @click="triggerPopover"
+  >
+    <template #addonAfter>
+      <IconPicker
+        ref="refIconPicker"
+        :icons="currentList"
+        :page-size="pageSize"
+        :value="modelValue"
+        @change="handleChange"
+      />
+    </template>
+  </Input>
+</template>

+ 126 - 0
apps/baicai-cms/src/components/form/components/bc-radio.vue

@@ -0,0 +1,126 @@
+<script setup lang="ts">
+import type { SelectValue } from 'ant-design-vue/es/select';
+
+import type { PropType } from 'vue';
+
+import type { ApiConfig } from '../types';
+
+import type { BasicOptionResult } from '#/api/model';
+
+import { computed, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { RadioGroup, Spin } from 'ant-design-vue';
+
+import { get, omit } from '#/utils';
+
+import { createApiFunction } from '../helper';
+
+const props = defineProps({
+  value: {
+    type: [String, Number, Array] as PropType<SelectValue>,
+    default: undefined,
+  },
+  numberToString: {
+    type: Boolean,
+    default: false,
+  },
+  api: {
+    type: Object as PropType<ApiConfig>,
+    default: () => ({
+      type: 'none',
+      method: 'get',
+      params: {},
+      result: '',
+      url: null,
+    }),
+  },
+  fieldNames: {
+    type: Object as PropType<{ label: string; options: any; value: string }>,
+    default: () => ({ label: 'label', value: 'value', options: null }),
+  },
+  immediate: {
+    type: Boolean,
+    default: true,
+  },
+  isBtn: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:value', 'optionsChange']);
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+const options = ref<BasicOptionResult[]>([]);
+const loading = ref(false);
+const isFirstLoad = ref(true);
+const getOptions = computed(() => {
+  const { fieldNames, numberToString } = props;
+  const res: BasicOptionResult[] = [];
+  unref(options).forEach((item: any) => {
+    const value = item[fieldNames.value];
+    res.push({
+      ...omit(item, [fieldNames.label, fieldNames.value]),
+      label: item[fieldNames.label],
+      value: numberToString ? `${value}` : value,
+      disabled: item.disabled || false,
+    });
+  });
+  return res;
+});
+
+const emitChange = () => {
+  emit('optionsChange', unref(getOptions));
+};
+
+const fetch = async () => {
+  const api = createApiFunction({ ...props.api });
+  if (!api || !isFunction(api)) return;
+  try {
+    loading.value = true;
+    const res = await api(props.api.params);
+    if (Array.isArray(res)) {
+      options.value = res;
+    } else {
+      options.value = props.api.result ? get(res, props.api.result) : [];
+    }
+    emitChange();
+  } catch (error) {
+    console.warn(error);
+  } finally {
+    loading.value = false;
+  }
+};
+watchEffect(() => {
+  props.immediate && fetch();
+});
+
+watch(
+  () => props.api.params,
+  () => {
+    !unref(isFirstLoad) && fetch();
+  },
+  { deep: true },
+);
+</script>
+
+<template>
+  <Spin :spinning="loading" style="margin-left: 20px">
+    <RadioGroup
+      v-bind="$attrs"
+      v-model:value="modelValue"
+      :button-style="isBtn ? 'solid' : 'outline'"
+      :option-type="isBtn ? 'button' : 'default'"
+      :options="getOptions"
+      class="w-full"
+    >
+      <template v-for="item in Object.keys($slots)" #[item]="data">
+        <slot :name="item" v-bind="data || {}"></slot>
+      </template>
+    </RadioGroup>
+  </Spin>
+</template>

+ 156 - 0
apps/baicai-cms/src/components/form/components/bc-select.vue

@@ -0,0 +1,156 @@
+<script setup lang="ts">
+import type { DefaultOptionType, SelectValue } from 'ant-design-vue/es/select';
+
+import type { PropType } from 'vue';
+
+import type { ApiConfig } from '../types';
+
+import type { BasicOptionResult } from '#/api/model';
+
+import { computed, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { Select } from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+import { get, omit } from '#/utils';
+
+import { createApiFunction } from '../helper';
+
+const props = defineProps({
+  value: {
+    type: [String, Number, Array] as PropType<SelectValue>,
+    default: undefined,
+  },
+  numberToString: {
+    type: Boolean,
+    default: false,
+  },
+  api: {
+    type: Object as PropType<ApiConfig>,
+    default: () => ({
+      type: 'none',
+      method: 'get',
+      params: {},
+      result: '',
+      url: null,
+    }),
+  },
+  fieldNames: {
+    type: Object as PropType<{ label: string; options: any; value: string }>,
+    default: () => ({ label: 'label', value: 'value', options: null }),
+  },
+  immediate: {
+    type: Boolean,
+    default: true,
+  },
+  showSearch: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:value', 'optionsChange']);
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+const options = ref<BasicOptionResult[]>([]);
+const loading = ref(false);
+const isFirstLoad = ref(true);
+
+const getOptions = computed(() => {
+  const { fieldNames, numberToString } = props;
+  const res: BasicOptionResult[] = [];
+  unref(options).forEach((item: any) => {
+    const value = item[fieldNames.value];
+    res.push({
+      ...omit(item, [fieldNames.label, fieldNames.value]),
+      label: item[fieldNames.label],
+      value: numberToString ? `${value}` : value,
+      disabled: item.disabled || false,
+    });
+  });
+  return res as DefaultOptionType[];
+});
+
+const emitChange = () => {
+  emit('optionsChange', unref(options));
+};
+
+const fetch = async () => {
+  const api = createApiFunction({ ...props.api });
+  if (!api || !isFunction(api)) return;
+  try {
+    loading.value = true;
+    const res = await api(props.api.params);
+    if (Array.isArray(res)) {
+      options.value = res;
+    } else {
+      options.value = props.api.result ? get(res, props.api.result) : [];
+    }
+    emitChange();
+  } catch (error) {
+    console.warn(error);
+  } finally {
+    loading.value = false;
+  }
+};
+const handleFetch = async () => {
+  if (!props.immediate && unref(isFirstLoad)) {
+    await fetch();
+    isFirstLoad.value = false;
+  }
+};
+
+const handelFilterOption = (input: string, option: any) => {
+  const { fieldNames } = props;
+  return (
+    `${option[fieldNames.value]}`
+      .toLowerCase()
+      .includes(`${input}`.toLowerCase()) ||
+    option[fieldNames.label]
+      .toString()
+      .toLowerCase()
+      .includes(input.toLowerCase())
+  );
+};
+
+watchEffect(() => {
+  props.immediate && fetch();
+});
+
+watch(
+  () => props.api.params,
+  () => {
+    !unref(isFirstLoad) && fetch();
+  },
+  { deep: true },
+);
+</script>
+
+<template>
+  <Select
+    v-model:value="modelValue"
+    :options="getOptions"
+    v-bind="$attrs"
+    :filter-option="handelFilterOption"
+    :show-search="showSearch"
+    class="w-full"
+    @dropdown-visible-change="handleFetch"
+  >
+    <template v-for="item in Object.keys($slots)" #[item]="data">
+      <slot :name="item" v-bind="data || {}"></slot>
+    </template>
+    <template v-if="loading" #suffixIcon>
+      <Icon icon="ant-design:loading-outlined" spin />
+    </template>
+    <template v-if="loading" #notFoundContent>
+      <span>
+        <Icon class="mr-1" icon="ant-design:loading-outlined" spin />
+        请等待数据加载完成
+      </span>
+    </template>
+  </Select>
+</template>

+ 127 - 0
apps/baicai-cms/src/components/form/components/bc-tree-select.vue

@@ -0,0 +1,127 @@
+<script setup lang="ts">
+import type { SelectValue } from 'ant-design-vue/es/select';
+
+import type { PropType } from 'vue';
+
+import type { ApiConfig } from '../types';
+
+import { computed, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { TreeSelect } from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+import { get } from '#/utils';
+
+import { createApiFunction } from '../helper';
+
+const props = defineProps({
+  value: {
+    type: [String, Number, Array] as PropType<SelectValue>,
+    default: undefined,
+  },
+  api: {
+    type: Object as PropType<ApiConfig>,
+    default: () => ({
+      type: 'none',
+      method: 'get',
+      params: {},
+      result: '',
+      url: null,
+    }),
+  },
+  fieldNames: {
+    type: Object as PropType<{
+      children: string;
+      label: string;
+      value: string;
+    }>,
+    default: () => ({ label: 'label', value: 'value', children: 'children' }),
+  },
+  immediate: {
+    type: Boolean,
+    default: true,
+  },
+  showSearch: {
+    type: Boolean,
+    default: false,
+  },
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emit = defineEmits(['update:value', 'optionsChange']);
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+const treeData = ref<Record<string, any>[]>([]);
+const loading = ref(false);
+const isFirstLoad = ref(true);
+const getTreeData = computed(() => {
+  return unref(treeData);
+});
+
+const emitChange = () => {
+  emit('optionsChange', unref(treeData));
+};
+
+const fetch = async () => {
+  const api = createApiFunction({ ...props.api });
+  if (!api || !isFunction(api)) return;
+  try {
+    loading.value = true;
+    const res = await api(props.api.params);
+    if (Array.isArray(res)) {
+      treeData.value = res || [];
+    } else {
+      treeData.value = props.api.result ? get(res, props.api.result) : [];
+    }
+    emitChange();
+  } catch (error) {
+    console.warn(error);
+  } finally {
+    loading.value = false;
+  }
+};
+async function handleFetch() {
+  if (!props.immediate && unref(isFirstLoad)) {
+    await fetch();
+    isFirstLoad.value = false;
+  }
+}
+watchEffect(() => {
+  props.immediate && fetch();
+});
+
+watch(
+  () => props.api.params,
+  () => {
+    !isFirstLoad.value && fetch();
+  },
+  { deep: true },
+);
+</script>
+
+<template>
+  <TreeSelect
+    v-model:value="modelValue"
+    :field-names="fieldNames"
+    :show-search="showSearch"
+    :tree-data="getTreeData"
+    :tree-node-filter-prop="fieldNames.label"
+    class="w-full"
+    @dropdown-visible-change="handleFetch"
+    :multiple="multiple"
+  >
+    <template v-for="item in Object.keys($slots)" #[item]="data">
+      <slot :name="item" v-bind="data || {}"></slot>
+    </template>
+    <template v-if="loading" #suffixIcon>
+      <Icon icon="ant-design:loading-outlined" spin />
+    </template>
+  </TreeSelect>
+</template>

+ 88 - 0
apps/baicai-cms/src/components/form/components/input-code.vue

@@ -0,0 +1,88 @@
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+import { reactive, watch } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { useVModel } from '@vueuse/core';
+import { Input } from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+
+import InputCode from './input-code/input-code-modal-ignore.vue';
+
+const props = defineProps({
+  value: {
+    type: [String, Object, Array] as PropType<
+      any[] | Record<string, any> | string
+    >,
+    default: undefined,
+  },
+  language: {
+    type: String as PropType<string>,
+    default: 'javascript',
+  },
+  placeholder: {
+    type: String as PropType<string>,
+    default: '请输入',
+  },
+});
+const emit = defineEmits(['update:value']);
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+
+const state = reactive({
+  value: '',
+});
+
+const [InputCodeModal, inputCodeApi] = useVbenModal({
+  connectedComponent: InputCode,
+});
+
+watch(
+  () => props.value,
+  () => {
+    if (props.value) {
+      state.value = '已配置';
+    }
+  },
+  { deep: true },
+);
+
+const handleSuccess = (scriptCode: any) => {
+  if (modelValue.value !== scriptCode) {
+    emit('update:value', scriptCode);
+  }
+};
+
+const handleInput = () => {
+  const { language } = props;
+  inputCodeApi
+    .setData({
+      baseData: {
+        scriptCode: modelValue.value,
+        name: '验证规则',
+        language,
+      },
+    })
+    .open();
+};
+</script>
+
+<template>
+  <div class="w-full">
+    <InputCodeModal @success="handleSuccess" />
+    <Input
+      v-model:value="state.value"
+      class="w-full"
+      :placeholder="placeholder"
+    >
+      <template #addonAfter>
+        <Icon icon="proicons:nodejs" @click="handleInput" />
+      </template>
+    </Input>
+  </div>
+</template>

+ 147 - 0
apps/baicai-cms/src/components/form/components/input-code/input-code-modal-ignore.vue

@@ -0,0 +1,147 @@
+<script lang="ts" setup>
+import { nextTick, reactive, ref, toRaw, unref } from 'vue';
+
+import { alert, useVbenModal } from '@vben/common-ui';
+import { isString } from '@vben/utils';
+
+import * as monaco from 'monaco-editor';
+import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
+import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
+import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
+import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
+import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
+
+defineOptions({
+  name: 'InputCodePreview',
+});
+const emit = defineEmits(['success']);
+const state = reactive<{
+  language: string;
+  title: string;
+}>({
+  title: '脚本编辑',
+  language: '',
+});
+
+const isPreview = ref(false);
+
+// eslint-disable-next-line no-restricted-globals
+self.MonacoEnvironment = {
+  getWorker: (_: string, label: string) => {
+    if (label === 'json') {
+      return new JsonWorker();
+    }
+    if (['css', 'less', 'scss'].includes(label)) {
+      return new CssWorker();
+    }
+    if (['handlebars', 'html', 'razor'].includes(label)) {
+      return new HtmlWorker();
+    }
+    if (['javascript', 'typescript'].includes(label)) {
+      return new TsWorker();
+    }
+    return new EditorWorker();
+  },
+};
+const monacoEditorRef = ref();
+// 初始化monacoEditor对象
+let monacoEditor: any = null;
+
+const initMonacoEditor = () => {
+  monacoEditor = monaco.editor.create(monacoEditorRef.value, {
+    theme: 'vs-dark', // 主题 vs vs-dark hc-black
+    value: '', // 默认显示的值
+    language: 'javascript',
+    formatOnPaste: true,
+    wordWrap: 'on', // 自动换行,注意大小写
+    wrappingIndent: 'indent',
+    folding: true, // 是否折叠
+    foldingHighlight: true, // 折叠等高线
+    foldingStrategy: 'indentation', // 折叠方式  auto | indentation
+    showFoldingControls: 'always', // 是否一直显示折叠 always | mouSEOver
+    disableLayerHinting: true, // 等宽优化
+    emptySelectionClipboard: false, // 空选择剪切板
+    selectionClipboard: false, // 选择剪切板
+    automaticLayout: true, // 自动布局
+    codeLens: false, // 代码镜头
+    scrollBeyondLastLine: false, // 滚动完最后一行后再滚动一屏幕
+    colorDecorators: true, // 颜色装饰器
+    accessibilitySupport: 'auto', // 辅助功能支持  "auto" | "off" | "on"
+    lineNumbers: 'on', // 行号 取值: "on" | "off" | "relative" | "interval" | function
+    lineNumbersMinChars: 5, // 行号最小字符   number
+    // enableSplitViewResizing: false,
+    readOnly: false, // 是否只读  取值 true | false
+  });
+};
+
+const [Modal, { lock, unlock, close, setState, getData }] = useVbenModal({
+  draggable: true,
+  fullscreen: false,
+  closeOnClickModal: false,
+  onClosed: () => {
+    monacoEditor.dispose();
+    monacoEditor = null;
+  },
+  onConfirm: async () => {
+    try {
+      lock();
+      const scriptCode = toRaw(monacoEditor).getValue();
+      if (['javascript', 'json'].includes(state.language)) {
+        try {
+          emit('success', JSON.parse(scriptCode));
+          close();
+        } catch (error) {
+          alert({ content: '数据格式错误', icon: 'error' });
+          // eslint-disable-next-line no-console
+          console.log(
+            '数据格式错误',
+            `语言 ${state.language}  ${error}  ${typeof scriptCode}`,
+          );
+        }
+      } else {
+        emit('success', scriptCode);
+        close();
+      }
+    } finally {
+      setState({ confirmLoading: false });
+      unlock();
+    }
+  },
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+      isPreview.value = !!data?.isPreview;
+      setState({ footer: !unref(isPreview) });
+      if (monacoEditor === null) {
+        nextTick(() => {
+          initMonacoEditor();
+        });
+      }
+      nextTick(() => {
+        const { scriptCode, name, language } = data.baseData;
+        state.title = name;
+        state.language = language;
+        if (language) {
+          monaco.editor.setModelLanguage(monacoEditor.getModel()!, language);
+        }
+
+        if (scriptCode) {
+          if (isString(scriptCode)) {
+            monacoEditor.setValue(scriptCode);
+          } else {
+            monacoEditor.setValue(JSON.stringify(scriptCode));
+          }
+          monacoEditor.getAction('editor.action.formatDocument').run();
+        }
+        setState({ loading: false });
+      });
+    }
+  },
+});
+</script>
+<template>
+  <Modal class="h-[800px] w-[1000px]" :title="state.title">
+    <div ref="monacoEditorRef" class="h-full w-full"></div>
+  </Modal>
+</template>

+ 32 - 0
apps/baicai-cms/src/components/form/helper.ts

@@ -0,0 +1,32 @@
+import type { ApiConfig } from './types';
+
+// import { DictionaryApi, EnumApi, QueryApi } from '#/api';
+import { requestClient } from '#/api/request';
+
+export const createApiFunction = (apiConfig: ApiConfig) => {
+  const api: any =
+    apiConfig.url === undefined ||
+    (typeof apiConfig.url === 'string' && !apiConfig.url)
+      ? (params: any) => {
+          switch (apiConfig.type) {
+            case 'api': {
+              return null; // return QueryApi.postOptions(params);
+            }
+            case 'dict': {
+              return null; // return DictionaryApi.getItemList(params);
+            }
+            case 'enum': {
+              return null; // return EnumApi.getList(params);
+            }
+            default: {
+              const method = apiConfig.method || 'get';
+              return (requestClient as any)[method](
+                apiConfig.url as any,
+                method === 'get' ? { params } : params,
+              );
+            }
+          }
+        }
+      : apiConfig.url;
+  return api;
+};

+ 19 - 0
apps/baicai-cms/src/components/form/types/index.d.ts

@@ -0,0 +1,19 @@
+export type CustomComponentType =
+  | 'BcCheckbox'
+  | 'BcRadio'
+  | 'BcSelect'
+  | 'BcTreeSelect';
+
+export type ApiConfig = {
+  method?: string;
+  params?: PropType<object>;
+  result?: string;
+  type?: 'api' | 'dict' | 'enum' | 'none';
+  url: PropType<(arg?: any) => Promise<BasicOptionResult[]> | string>;
+};
+
+export interface FieldNames {
+  children?: string;
+  label: string;
+  value: string;
+}

+ 793 - 0
apps/baicai-cms/src/components/icon/icon.data.ts

@@ -0,0 +1,793 @@
+export default {
+  icons: [
+    'account-book-filled',
+    'account-book-outlined',
+    'account-book-twotone',
+    'aim-outlined',
+    'alert-filled',
+    'alert-outlined',
+    'alert-twotone',
+    'alibaba-outlined',
+    'align-center-outlined',
+    'align-left-outlined',
+    'align-right-outlined',
+    'alipay-circle-filled',
+    'alipay-circle-outlined',
+    'alipay-outlined',
+    'alipay-square-filled',
+    'aliwangwang-filled',
+    'aliwangwang-outlined',
+    'aliyun-outlined',
+    'amazon-circle-filled',
+    'amazon-outlined',
+    'amazon-square-filled',
+    'android-filled',
+    'android-outlined',
+    'ant-cloud-outlined',
+    'ant-design-outlined',
+    'apartment-outlined',
+    'api-filled',
+    'api-outlined',
+    'api-twotone',
+    'apple-filled',
+    'apple-outlined',
+    'appstore-add-outlined',
+    'appstore-filled',
+    'appstore-outlined',
+    'appstore-twotone',
+    'area-chart-outlined',
+    'arrow-down-outlined',
+    'arrow-left-outlined',
+    'arrow-right-outlined',
+    'arrow-up-outlined',
+    'arrows-alt-outlined',
+    'audio-filled',
+    'audio-muted-outlined',
+    'audio-outlined',
+    'audio-twotone',
+    'audit-outlined',
+    'backward-filled',
+    'backward-outlined',
+    'bank-filled',
+    'bank-outlined',
+    'bank-twotone',
+    'bar-chart-outlined',
+    'barcode-outlined',
+    'bars-outlined',
+    'behance-circle-filled',
+    'behance-outlined',
+    'behance-square-filled',
+    'behance-square-outlined',
+    'bell-filled',
+    'bell-outlined',
+    'bell-twotone',
+    'bg-colors-outlined',
+    'block-outlined',
+    'bold-outlined',
+    'book-filled',
+    'book-outlined',
+    'book-twotone',
+    'border-bottom-outlined',
+    'border-horizontal-outlined',
+    'border-inner-outlined',
+    'border-left-outlined',
+    'border-outer-outlined',
+    'border-outlined',
+    'border-right-outlined',
+    'border-top-outlined',
+    'border-verticle-outlined',
+    'borderless-table-outlined',
+    'box-plot-filled',
+    'box-plot-outlined',
+    'box-plot-twotone',
+    'branches-outlined',
+    'bug-filled',
+    'bug-outlined',
+    'bug-twotone',
+    'build-filled',
+    'build-outlined',
+    'build-twotone',
+    'bulb-filled',
+    'bulb-outlined',
+    'bulb-twotone',
+    'calculator-filled',
+    'calculator-outlined',
+    'calculator-twotone',
+    'calendar-filled',
+    'calendar-outlined',
+    'calendar-twotone',
+    'camera-filled',
+    'camera-outlined',
+    'camera-twotone',
+    'car-filled',
+    'car-outlined',
+    'car-twotone',
+    'caret-down-filled',
+    'caret-down-outlined',
+    'caret-left-filled',
+    'caret-left-outlined',
+    'caret-right-filled',
+    'caret-right-outlined',
+    'caret-up-filled',
+    'caret-up-outlined',
+    'carry-out-filled',
+    'carry-out-outlined',
+    'carry-out-twotone',
+    'check-circle-filled',
+    'check-circle-outlined',
+    'check-circle-twotone',
+    'check-outlined',
+    'check-square-filled',
+    'check-square-outlined',
+    'check-square-twotone',
+    'chrome-filled',
+    'chrome-outlined',
+    'ci-circle-filled',
+    'ci-circle-outlined',
+    'ci-circle-twotone',
+    'ci-outlined',
+    'ci-twotone',
+    'clear-outlined',
+    'clock-circle-filled',
+    'clock-circle-outlined',
+    'clock-circle-twotone',
+    'close-circle-filled',
+    'close-circle-outlined',
+    'close-circle-twotone',
+    'close-outlined',
+    'close-square-filled',
+    'close-square-outlined',
+    'close-square-twotone',
+    'cloud-download-outlined',
+    'cloud-filled',
+    'cloud-outlined',
+    'cloud-server-outlined',
+    'cloud-sync-outlined',
+    'cloud-twotone',
+    'cloud-upload-outlined',
+    'cluster-outlined',
+    'code-filled',
+    'code-outlined',
+    'code-sandbox-circle-filled',
+    'code-sandbox-outlined',
+    'code-sandbox-square-filled',
+    'code-twotone',
+    'codepen-circle-filled',
+    'codepen-circle-outlined',
+    'codepen-outlined',
+    'codepen-square-filled',
+    'coffee-outlined',
+    'column-height-outlined',
+    'column-width-outlined',
+    'comment-outlined',
+    'compass-filled',
+    'compass-outlined',
+    'compass-twotone',
+    'compress-outlined',
+    'console-sql-outlined',
+    'contacts-filled',
+    'contacts-outlined',
+    'contacts-twotone',
+    'container-filled',
+    'container-outlined',
+    'container-twotone',
+    'control-filled',
+    'control-outlined',
+    'control-twotone',
+    'copy-filled',
+    'copy-outlined',
+    'copy-twotone',
+    'copyright-circle-filled',
+    'copyright-circle-outlined',
+    'copyright-circle-twotone',
+    'copyright-outlined',
+    'copyright-twotone',
+    'credit-card-filled',
+    'credit-card-outlined',
+    'credit-card-twotone',
+    'crown-filled',
+    'crown-outlined',
+    'crown-twotone',
+    'customer-service-filled',
+    'customer-service-outlined',
+    'customer-service-twotone',
+    'dash-outlined',
+    'dashboard-filled',
+    'dashboard-outlined',
+    'dashboard-twotone',
+    'database-filled',
+    'database-outlined',
+    'database-twotone',
+    'delete-column-outlined',
+    'delete-filled',
+    'delete-outlined',
+    'delete-row-outlined',
+    'delete-twotone',
+    'delivered-procedure-outlined',
+    'deployment-unit-outlined',
+    'desktop-outlined',
+    'diff-filled',
+    'diff-outlined',
+    'diff-twotone',
+    'dingding-outlined',
+    'dingtalk-circle-filled',
+    'dingtalk-outlined',
+    'dingtalk-square-filled',
+    'disconnect-outlined',
+    'dislike-filled',
+    'dislike-outlined',
+    'dislike-twotone',
+    'dollar-circle-filled',
+    'dollar-circle-outlined',
+    'dollar-circle-twotone',
+    'dollar-outlined',
+    'dollar-twotone',
+    'dot-chart-outlined',
+    'double-left-outlined',
+    'double-right-outlined',
+    'down-circle-filled',
+    'down-circle-outlined',
+    'down-circle-twotone',
+    'down-outlined',
+    'down-square-filled',
+    'down-square-outlined',
+    'down-square-twotone',
+    'download-outlined',
+    'drag-outlined',
+    'dribbble-circle-filled',
+    'dribbble-outlined',
+    'dribbble-square-filled',
+    'dribbble-square-outlined',
+    'dropbox-circle-filled',
+    'dropbox-outlined',
+    'dropbox-square-filled',
+    'edit-filled',
+    'edit-outlined',
+    'edit-twotone',
+    'ellipsis-outlined',
+    'enter-outlined',
+    'environment-filled',
+    'environment-outlined',
+    'environment-twotone',
+    'euro-circle-filled',
+    'euro-circle-outlined',
+    'euro-circle-twotone',
+    'euro-outlined',
+    'euro-twotone',
+    'exception-outlined',
+    'exclamation-circle-filled',
+    'exclamation-circle-outlined',
+    'exclamation-circle-twotone',
+    'exclamation-outlined',
+    'expand-alt-outlined',
+    'expand-outlined',
+    'experiment-filled',
+    'experiment-outlined',
+    'experiment-twotone',
+    'export-outlined',
+    'eye-filled',
+    'eye-invisible-filled',
+    'eye-invisible-outlined',
+    'eye-invisible-twotone',
+    'eye-outlined',
+    'eye-twotone',
+    'facebook-filled',
+    'facebook-outlined',
+    'fall-outlined',
+    'fast-backward-filled',
+    'fast-backward-outlined',
+    'fast-forward-filled',
+    'fast-forward-outlined',
+    'field-binary-outlined',
+    'field-number-outlined',
+    'field-string-outlined',
+    'field-time-outlined',
+    'file-add-filled',
+    'file-add-outlined',
+    'file-add-twotone',
+    'file-done-outlined',
+    'file-excel-filled',
+    'file-excel-outlined',
+    'file-excel-twotone',
+    'file-exclamation-filled',
+    'file-exclamation-outlined',
+    'file-exclamation-twotone',
+    'file-filled',
+    'file-gif-outlined',
+    'file-image-filled',
+    'file-image-outlined',
+    'file-image-twotone',
+    'file-jpg-outlined',
+    'file-markdown-filled',
+    'file-markdown-outlined',
+    'file-markdown-twotone',
+    'file-outlined',
+    'file-pdf-filled',
+    'file-pdf-outlined',
+    'file-pdf-twotone',
+    'file-ppt-filled',
+    'file-ppt-outlined',
+    'file-ppt-twotone',
+    'file-protect-outlined',
+    'file-search-outlined',
+    'file-sync-outlined',
+    'file-text-filled',
+    'file-text-outlined',
+    'file-text-twotone',
+    'file-twotone',
+    'file-unknown-filled',
+    'file-unknown-outlined',
+    'file-unknown-twotone',
+    'file-word-filled',
+    'file-word-outlined',
+    'file-word-twotone',
+    'file-zip-filled',
+    'file-zip-outlined',
+    'file-zip-twotone',
+    'filter-filled',
+    'filter-outlined',
+    'filter-twotone',
+    'fire-filled',
+    'fire-outlined',
+    'fire-twotone',
+    'flag-filled',
+    'flag-outlined',
+    'flag-twotone',
+    'folder-add-filled',
+    'folder-add-outlined',
+    'folder-add-twotone',
+    'folder-filled',
+    'folder-open-filled',
+    'folder-open-outlined',
+    'folder-open-twotone',
+    'folder-outlined',
+    'folder-twotone',
+    'folder-view-outlined',
+    'font-colors-outlined',
+    'font-size-outlined',
+    'fork-outlined',
+    'form-outlined',
+    'format-painter-filled',
+    'format-painter-outlined',
+    'forward-filled',
+    'forward-outlined',
+    'frown-filled',
+    'frown-outlined',
+    'frown-twotone',
+    'fullscreen-exit-outlined',
+    'fullscreen-outlined',
+    'function-outlined',
+    'fund-filled',
+    'fund-outlined',
+    'fund-projection-screen-outlined',
+    'fund-twotone',
+    'fund-view-outlined',
+    'funnel-plot-filled',
+    'funnel-plot-outlined',
+    'funnel-plot-twotone',
+    'gateway-outlined',
+    'gif-outlined',
+    'gift-filled',
+    'gift-outlined',
+    'gift-twotone',
+    'github-filled',
+    'github-outlined',
+    'gitlab-filled',
+    'gitlab-outlined',
+    'global-outlined',
+    'gold-filled',
+    'gold-outlined',
+    'gold-twotone',
+    'golden-filled',
+    'google-circle-filled',
+    'google-outlined',
+    'google-plus-circle-filled',
+    'google-plus-outlined',
+    'google-plus-square-filled',
+    'google-square-filled',
+    'group-outlined',
+    'hdd-filled',
+    'hdd-outlined',
+    'hdd-twotone',
+    'heart-filled',
+    'heart-outlined',
+    'heart-twotone',
+    'heat-map-outlined',
+    'highlight-filled',
+    'highlight-outlined',
+    'highlight-twotone',
+    'history-outlined',
+    'home-filled',
+    'home-outlined',
+    'home-twotone',
+    'hourglass-filled',
+    'hourglass-outlined',
+    'hourglass-twotone',
+    'html5-filled',
+    'html5-outlined',
+    'html5-twotone',
+    'idcard-filled',
+    'idcard-outlined',
+    'idcard-twotone',
+    'ie-circle-filled',
+    'ie-outlined',
+    'ie-square-filled',
+    'import-outlined',
+    'inbox-outlined',
+    'info-circle-filled',
+    'info-circle-outlined',
+    'info-circle-twotone',
+    'info-outlined',
+    'insert-row-above-outlined',
+    'insert-row-below-outlined',
+    'insert-row-left-outlined',
+    'insert-row-right-outlined',
+    'instagram-filled',
+    'instagram-outlined',
+    'insurance-filled',
+    'insurance-outlined',
+    'insurance-twotone',
+    'interaction-filled',
+    'interaction-outlined',
+    'interaction-twotone',
+    'issues-close-outlined',
+    'italic-outlined',
+    'key-outlined',
+    'laptop-outlined',
+    'layout-filled',
+    'layout-outlined',
+    'layout-twotone',
+    'left-circle-filled',
+    'left-circle-outlined',
+    'left-circle-twotone',
+    'left-outlined',
+    'left-square-filled',
+    'left-square-outlined',
+    'left-square-twotone',
+    'like-filled',
+    'like-outlined',
+    'like-twotone',
+    'line-chart-outlined',
+    'line-height-outlined',
+    'line-outlined',
+    'link-outlined',
+    'linkedin-filled',
+    'linkedin-outlined',
+    'loading-3-quarters-outlined',
+    'loading-outlined',
+    'lock-filled',
+    'lock-outlined',
+    'lock-twotone',
+    'login-outlined',
+    'logout-outlined',
+    'mac-command-filled',
+    'mac-command-outlined',
+    'mail-filled',
+    'mail-outlined',
+    'mail-twotone',
+    'man-outlined',
+    'medicine-box-filled',
+    'medicine-box-outlined',
+    'medicine-box-twotone',
+    'medium-circle-filled',
+    'medium-outlined',
+    'medium-square-filled',
+    'medium-workmark-outlined',
+    'meh-filled',
+    'meh-outlined',
+    'meh-twotone',
+    'menu-fold-outlined',
+    'menu-outlined',
+    'menu-unfold-outlined',
+    'merge-cells-outlined',
+    'message-filled',
+    'message-outlined',
+    'message-twotone',
+    'minus-circle-filled',
+    'minus-circle-outlined',
+    'minus-circle-twotone',
+    'minus-outlined',
+    'minus-square-filled',
+    'minus-square-outlined',
+    'minus-square-twotone',
+    'mobile-filled',
+    'mobile-outlined',
+    'mobile-twotone',
+    'money-collect-filled',
+    'money-collect-outlined',
+    'money-collect-twotone',
+    'monitor-outlined',
+    'more-outlined',
+    'node-collapse-outlined',
+    'node-expand-outlined',
+    'node-index-outlined',
+    'notification-filled',
+    'notification-outlined',
+    'notification-twotone',
+    'number-outlined',
+    'one-to-one-outlined',
+    'ordered-list-outlined',
+    'paper-clip-outlined',
+    'partition-outlined',
+    'pause-circle-filled',
+    'pause-circle-outlined',
+    'pause-circle-twotone',
+    'pause-outlined',
+    'pay-circle-filled',
+    'pay-circle-outlined',
+    'percentage-outlined',
+    'phone-filled',
+    'phone-outlined',
+    'phone-twotone',
+    'pic-center-outlined',
+    'pic-left-outlined',
+    'pic-right-outlined',
+    'picture-filled',
+    'picture-outlined',
+    'picture-twotone',
+    'pie-chart-filled',
+    'pie-chart-outlined',
+    'pie-chart-twotone',
+    'play-circle-filled',
+    'play-circle-outlined',
+    'play-circle-twotone',
+    'play-square-filled',
+    'play-square-outlined',
+    'play-square-twotone',
+    'plus-circle-filled',
+    'plus-circle-outlined',
+    'plus-circle-twotone',
+    'plus-outlined',
+    'plus-square-filled',
+    'plus-square-outlined',
+    'plus-square-twotone',
+    'pound-circle-filled',
+    'pound-circle-outlined',
+    'pound-circle-twotone',
+    'pound-outlined',
+    'poweroff-outlined',
+    'printer-filled',
+    'printer-outlined',
+    'printer-twotone',
+    'profile-filled',
+    'profile-outlined',
+    'profile-twotone',
+    'project-filled',
+    'project-outlined',
+    'project-twotone',
+    'property-safety-filled',
+    'property-safety-outlined',
+    'property-safety-twotone',
+    'pull-request-outlined',
+    'pushpin-filled',
+    'pushpin-outlined',
+    'pushpin-twotone',
+    'qq-circle-filled',
+    'qq-outlined',
+    'qq-square-filled',
+    'qrcode-outlined',
+    'question-circle-filled',
+    'question-circle-outlined',
+    'question-circle-twotone',
+    'question-outlined',
+    'radar-chart-outlined',
+    'radius-bottomleft-outlined',
+    'radius-bottomright-outlined',
+    'radius-setting-outlined',
+    'radius-upleft-outlined',
+    'radius-upright-outlined',
+    'read-filled',
+    'read-outlined',
+    'reconciliation-filled',
+    'reconciliation-outlined',
+    'reconciliation-twotone',
+    'red-envelope-filled',
+    'red-envelope-outlined',
+    'red-envelope-twotone',
+    'reddit-circle-filled',
+    'reddit-outlined',
+    'reddit-square-filled',
+    'redo-outlined',
+    'reload-outlined',
+    'rest-filled',
+    'rest-outlined',
+    'rest-twotone',
+    'retweet-outlined',
+    'right-circle-filled',
+    'right-circle-outlined',
+    'right-circle-twotone',
+    'right-outlined',
+    'right-square-filled',
+    'right-square-outlined',
+    'right-square-twotone',
+    'rise-outlined',
+    'robot-filled',
+    'robot-outlined',
+    'rocket-filled',
+    'rocket-outlined',
+    'rocket-twotone',
+    'rollback-outlined',
+    'rotate-left-outlined',
+    'rotate-right-outlined',
+    'safety-certificate-filled',
+    'safety-certificate-outlined',
+    'safety-certificate-twotone',
+    'safety-outlined',
+    'save-filled',
+    'save-outlined',
+    'save-twotone',
+    'scan-outlined',
+    'schedule-filled',
+    'schedule-outlined',
+    'schedule-twotone',
+    'scissor-outlined',
+    'search-outlined',
+    'security-scan-filled',
+    'security-scan-outlined',
+    'security-scan-twotone',
+    'select-outlined',
+    'send-outlined',
+    'setting-filled',
+    'setting-outlined',
+    'setting-twotone',
+    'shake-outlined',
+    'share-alt-outlined',
+    'shop-filled',
+    'shop-outlined',
+    'shop-twotone',
+    'shopping-cart-outlined',
+    'shopping-filled',
+    'shopping-outlined',
+    'shopping-twotone',
+    'shrink-outlined',
+    'signal-filled',
+    'sisternode-outlined',
+    'sketch-circle-filled',
+    'sketch-outlined',
+    'sketch-square-filled',
+    'skin-filled',
+    'skin-outlined',
+    'skin-twotone',
+    'skype-filled',
+    'skype-outlined',
+    'slack-circle-filled',
+    'slack-outlined',
+    'slack-square-filled',
+    'slack-square-outlined',
+    'sliders-filled',
+    'sliders-outlined',
+    'sliders-twotone',
+    'small-dash-outlined',
+    'smile-filled',
+    'smile-outlined',
+    'smile-twotone',
+    'snippets-filled',
+    'snippets-outlined',
+    'snippets-twotone',
+    'solution-outlined',
+    'sort-ascending-outlined',
+    'sort-descending-outlined',
+    'sound-filled',
+    'sound-outlined',
+    'sound-twotone',
+    'split-cells-outlined',
+    'star-filled',
+    'star-outlined',
+    'star-twotone',
+    'step-backward-filled',
+    'step-backward-outlined',
+    'step-forward-filled',
+    'step-forward-outlined',
+    'stock-outlined',
+    'stop-filled',
+    'stop-outlined',
+    'stop-twotone',
+    'strikethrough-outlined',
+    'subnode-outlined',
+    'swap-left-outlined',
+    'swap-outlined',
+    'swap-right-outlined',
+    'switcher-filled',
+    'switcher-outlined',
+    'switcher-twotone',
+    'sync-outlined',
+    'table-outlined',
+    'tablet-filled',
+    'tablet-outlined',
+    'tablet-twotone',
+    'tag-filled',
+    'tag-outlined',
+    'tag-twotone',
+    'tags-filled',
+    'tags-outlined',
+    'tags-twotone',
+    'taobao-circle-filled',
+    'taobao-circle-outlined',
+    'taobao-outlined',
+    'taobao-square-filled',
+    'team-outlined',
+    'thunderbolt-filled',
+    'thunderbolt-outlined',
+    'thunderbolt-twotone',
+    'to-top-outlined',
+    'tool-filled',
+    'tool-outlined',
+    'tool-twotone',
+    'trademark-circle-filled',
+    'trademark-circle-outlined',
+    'trademark-circle-twotone',
+    'trademark-outlined',
+    'transaction-outlined',
+    'translation-outlined',
+    'trophy-filled',
+    'trophy-outlined',
+    'trophy-twotone',
+    'twitter-circle-filled',
+    'twitter-outlined',
+    'twitter-square-filled',
+    'underline-outlined',
+    'undo-outlined',
+    'ungroup-outlined',
+    'unlock-filled',
+    'unlock-outlined',
+    'unlock-twotone',
+    'unordered-list-outlined',
+    'up-circle-filled',
+    'up-circle-outlined',
+    'up-circle-twotone',
+    'up-outlined',
+    'up-square-filled',
+    'up-square-outlined',
+    'up-square-twotone',
+    'upload-outlined',
+    'usb-filled',
+    'usb-outlined',
+    'usb-twotone',
+    'user-add-outlined',
+    'user-delete-outlined',
+    'user-outlined',
+    'user-switch-outlined',
+    'usergroup-add-outlined',
+    'usergroup-delete-outlined',
+    'verified-outlined',
+    'vertical-align-bottom-outlined',
+    'vertical-align-middle-outlined',
+    'vertical-align-top-outlined',
+    'vertical-left-outlined',
+    'vertical-right-outlined',
+    'video-camera-add-outlined',
+    'video-camera-filled',
+    'video-camera-outlined',
+    'video-camera-twotone',
+    'wallet-filled',
+    'wallet-outlined',
+    'wallet-twotone',
+    'warning-filled',
+    'warning-outlined',
+    'warning-twotone',
+    'wechat-filled',
+    'wechat-outlined',
+    'weibo-circle-filled',
+    'weibo-circle-outlined',
+    'weibo-outlined',
+    'weibo-square-filled',
+    'weibo-square-outlined',
+    'whats-app-outlined',
+    'wifi-outlined',
+    'windows-filled',
+    'windows-outlined',
+    'woman-outlined',
+    'yahoo-filled',
+    'yahoo-outlined',
+    'youtube-filled',
+    'youtube-outlined',
+    'yuque-filled',
+    'yuque-outlined',
+    'zhihu-circle-filled',
+    'zhihu-outlined',
+    'zhihu-square-filled',
+    'zoom-in-outlined',
+    'zoom-out-outlined',
+  ],
+  prefix: 'ant-design',
+};

+ 52 - 0
apps/baicai-cms/src/components/icon/icon.vue

@@ -0,0 +1,52 @@
+<script setup lang="ts">
+import { computed, h } from 'vue';
+
+import { IconifyIcon as VbenIcon } from '@vben/icons';
+
+const props = defineProps({
+  icon: {
+    type: String,
+    default: '',
+  },
+  size: {
+    type: [String, Number],
+    default: '16px',
+  },
+});
+const iconComp = computed(() => {
+  return props.icon.startsWith('http')
+    ? () => h('img', { src: props.icon, class: 'm-icon__' })
+    : '';
+  // return createIconifyIcon(props.icon);
+});
+
+const styles = computed(() => {
+  return {
+    fontSize: props.size.toString().endsWith('px')
+      ? props.size
+      : `${props.size}px`,
+  };
+});
+</script>
+
+<template>
+  <component :is="iconComp" v-if="iconComp" :style="styles" />
+  <VbenIcon v-else :icon="props.icon" :style="styles" class="m-icon__" />
+</template>
+<style lang="less" scoped>
+.m-icon__ {
+  display: inline-flex;
+  align-items: center;
+  width: 1em;
+  height: 1em;
+  font-style: normal;
+  line-height: 0;
+  color: inherit;
+  text-align: center;
+  text-transform: none;
+  vertical-align: -0.125em;
+  text-rendering: optimizelegibility;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+</style>

+ 1 - 0
apps/baicai-cms/src/components/icon/index.ts

@@ -0,0 +1 @@
+export { default as Icon } from './icon.vue';

BIN
apps/baicai-cms/src/components/select-card/images/head-default.png


BIN
apps/baicai-cms/src/components/select-card/images/head-female.png


BIN
apps/baicai-cms/src/components/select-card/images/head-male.png


BIN
apps/baicai-cms/src/components/select-card/images/role.png


+ 1 - 0
apps/baicai-cms/src/components/select-card/index.ts

@@ -0,0 +1 @@
+export { default as SelectCard } from './src/select-card.vue';

+ 176 - 0
apps/baicai-cms/src/components/select-card/src/select-card-item.vue

@@ -0,0 +1,176 @@
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+import type { Recordable } from '@vben/types';
+
+import { computed, useSlots } from 'vue';
+
+import { Tooltip } from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+
+import defaultImg from '../images/head-default.png';
+import FemaleImg from '../images/head-female.png';
+import MaleImg from '../images/head-male.png';
+import RoleImg from '../images/role.png';
+
+defineOptions({
+  name: 'SelectCardItem',
+});
+
+const props = defineProps({
+  model: {
+    type: Object as PropType<Recordable<any>>,
+    default: () => ({}),
+  },
+  fieldNames: {
+    type: Array as PropType<Array<any>>,
+    default: () => [
+      { title: '名称', value: 'title', maxLength: 12 },
+      { title: '电话', value: 'phone', maxLength: 8 },
+    ],
+  },
+  config: {
+    type: Object as PropType<{
+      bgcolor: string;
+      color: string;
+      fillColor: string;
+      type: string;
+    }>,
+    default: () => ({
+      type: 'none',
+      fillColor: '#f1ecfe',
+      bgcolor: '#f5f1fd',
+      color: '#b389ff',
+    }),
+  },
+  disabled: {
+    type: Boolean as PropType<boolean>,
+    default: false,
+  },
+  showTree: {
+    type: Boolean as PropType<boolean>,
+    default: false,
+  },
+  checked: {
+    type: Boolean as PropType<boolean>,
+    default: false,
+  },
+});
+
+const hasCheckSlot = computed(() => {
+  return !!useSlots().check;
+});
+
+const getImage = computed(() => {
+  switch (props.config.type) {
+    case 'role': {
+      return RoleImg;
+    }
+    case 'user': {
+      return props.model.sex === 1 ? MaleImg : FemaleImg;
+    }
+    default: {
+      return defaultImg;
+    }
+  }
+});
+
+const getFillColor = computed(() => {
+  if (props.config.type === 'user') {
+    return props.model.sex === 1 ? '#e9f0fe' : '#ffedf5';
+  } else {
+    return props.config.fillColor || '#f1ecfe';
+  }
+});
+
+const getFontColor = computed(() => {
+  if (props.config.type === 'user') {
+    return props.model.sex === 1 ? '#3c7eff' : '#ffd1d7';
+  } else {
+    return props.config.color || '#b389ff';
+  }
+});
+
+const getBgcolor = computed(() => {
+  if (props.config.type === 'user') {
+    return props.model.sex === 1 ? '#f3f8ff' : '#fef6fa';
+  } else {
+    return props.config.bgcolor || '#f5f1fd';
+  }
+});
+
+// const itemleftwidth = computed(() => {
+//   return props.showTree ? '30%' : '25%';
+// });
+</script>
+
+<template>
+  <div
+    class="card-box text-card-foreground border-border bg-muted relative w-full border border-dotted p-[16px] shadow"
+    :style="{
+      'border-color': checked ? getFontColor : '',
+    }"
+  >
+    <div class="relative flex">
+      <img :src="getImage" />
+      <div class="flex flex-1 flex-col justify-between py-1">
+        <div v-for="(item, index) in fieldNames" :key="index" class="pl-4">
+          <h4>
+            {{ item.title }}
+          </h4>
+          <Tooltip
+            v-if="
+              model[item.value] &&
+              model[item.value].length > model[item.value].maxLength
+            "
+            :title="model[item.value]"
+          >
+            <h4>
+              {{
+                `${model[item.value].slice(0, model[item.value].maxLength)}...`
+              }}
+            </h4>
+          </Tooltip>
+          <h4 v-else>
+            {{ model[item.value] || '-' }}
+          </h4>
+        </div>
+      </div>
+      <div
+        class="absolute right-[-22px] top-[-22px] rotate-[48deg] opacity-70 dark:opacity-10"
+      >
+        <Icon :color="getFillColor" :size="64" icon="fa6-solid:user-tie" />
+      </div>
+    </div>
+    <div v-if="hasCheckSlot" class="absolute bottom-[4px] right-[8px]">
+      <slot name="check"></slot>
+    </div>
+  </div>
+</template>
+
+<style lang="less" scoped>
+.select-card-item {
+  background: v-bind(getBgcolor);
+  &:hover {
+    border: 1px dotted v-bind(getFontColor);
+  }
+}
+
+:deep(.ant-checkbox-inner) {
+  border-color: v-bind(getFontColor);
+}
+
+:deep(.ant-checkbox-checked .ant-checkbox-inner) {
+  border-color: v-bind(getFontColor);
+  background-color: v-bind(getFontColor);
+}
+
+:deep(.ant-checkbox-checked::after),
+:deep(.ant-checkbox-wrapper:hover .ant-checkbox-inner, .ant-checkbox:hover),
+:deep(.ant-checkbox-inner),
+:deep(.ant-checkbox:hover),
+:deep(.ant-checkbox-input:focus + .ant-checkbox-inner) {
+  border-color: v-bind(getFontColor);
+}
+</style>

+ 277 - 0
apps/baicai-cms/src/components/select-card/src/select-card.vue

@@ -0,0 +1,277 @@
+<script lang="ts" setup>
+import type { PropType } from 'vue';
+
+import type { Recordable } from '@vben/types';
+
+import type { ApiConfig } from '../../form/types';
+
+import { reactive, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { isFunction } from '@vben/utils';
+
+import { Checkbox, message, Pagination } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { createApiFunction } from '#/components/form/helper';
+import { get } from '#/utils';
+
+import SelectCardItem from './select-card-item.vue';
+
+defineOptions({
+  name: 'SelectCard',
+});
+
+const props = defineProps({
+  multiple: {
+    type: Boolean as PropType<boolean>,
+    default: true,
+  },
+  title: {
+    type: String as PropType<string>,
+    default: '选择数据',
+  },
+  showTree: {
+    type: Boolean as PropType<boolean>,
+    default: false,
+  },
+  immediate: {
+    type: Boolean,
+    default: true,
+  },
+});
+const emit = defineEmits(['success']);
+const cardData = ref<Recordable<any>[]>([]);
+
+const state = reactive<{
+  api: ApiConfig;
+  config: {
+    bgcolor: string;
+    color: string;
+    fillColor: string;
+    type: string;
+  };
+  disabledIds: number[] | string[];
+  fieldNames: Recordable<any>[];
+  key: number | string;
+  keyName: string;
+  page: { pageIndex: number; pageSize: number; total: number };
+  searchName: string;
+  selectedIds: number[];
+  selectedList: Recordable<any>[];
+}>({
+  page: {
+    pageIndex: 1,
+    pageSize: 9,
+    total: 0,
+  },
+  selectedIds: [],
+  selectedList: [],
+  disabledIds: [],
+  api: {
+    type: 'none',
+    method: 'get',
+    params: {},
+    result: 'items',
+    url: null,
+  },
+  keyName: 'id',
+  key: '',
+  searchName: '',
+  fieldNames: [
+    { title: '名称', value: 'title', maxLength: 12 },
+    { title: '电话', value: 'phone', maxLength: 8 },
+  ],
+  config: {
+    type: 'none',
+    fillColor: '#f1ecfe',
+    bgcolor: '#f5f1fd',
+    color: '#b389ff',
+  },
+});
+const fetch = async () => {
+  const api = createApiFunction({ ...state.api });
+  if (!api || !isFunction(api)) return;
+  try {
+    const params = { ...state.page, ...state.api.params };
+    const res = await api(params);
+    if (Array.isArray(res)) {
+      cardData.value = res;
+    } else {
+      cardData.value = state.api.result ? get(res, state.api.result) : [];
+      state.page.total = res.total || 0;
+    }
+  } catch (error) {
+    console.warn(error);
+  } finally {
+    // setState({ loading: false });
+  }
+};
+
+const [Form, { resetForm }] = useVbenForm({
+  layout: 'horizontal',
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+  },
+  schema: [
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'keyword',
+      label: '关键字',
+    },
+  ],
+  submitButtonOptions: {
+    content: '查询',
+  },
+  wrapperClass: 'grid-cols-2',
+  handleSubmit: async (values: Record<string, any>) => {
+    if (state.searchName) {
+      state.page.pageIndex = 1;
+      state.api.params[state.searchName] = values.keyword;
+      await fetch();
+    }
+  },
+  handleReset: async () => {
+    resetForm();
+    state.page.pageIndex = 1;
+    state.api.params[state.searchName] = '';
+    await fetch();
+  },
+});
+
+const [Modal, { close, setState, getData }] = useVbenModal({
+  closeOnClickModal: false,
+  fullscreenButton: false,
+  onConfirm: async () => {
+    try {
+      close();
+      emit('success', {
+        key: state.key,
+        ids: [...state.selectedIds],
+        list: [...state.selectedList],
+      });
+    } catch {
+      message.error('操作失败');
+    } finally {
+      setState({ confirmLoading: false });
+    }
+  },
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+      state.key = data.baseData.key;
+      state.searchName = data.baseData.searchName || '';
+      state.selectedIds = data.baseData.selectedIds || [];
+      state.disabledIds = data.baseData.disabledIds || [];
+      state.fieldNames = data.baseData.fieldNames || [];
+      Object.assign(state.api, data.baseData.api);
+      Object.assign(state.config, data.baseData.config);
+      state.page.pageIndex = 1;
+      await fetch();
+      setState({ loading: false });
+    }
+  },
+});
+
+const hancelClick = (item: any) => {
+  if (
+    state.disabledIds &&
+    Array.isArray(state.disabledIds) &&
+    state.disabledIds.includes(item[state.keyName] as never)
+  ) {
+    return;
+  }
+
+  if (props.multiple) {
+    if (state.selectedIds.includes(item[state.keyName])) {
+      state.selectedIds.splice(
+        state.selectedIds.indexOf(item[state.keyName]),
+        1,
+      );
+      state.selectedList.splice(
+        state.selectedList.findIndex(
+          (ele) => ele[state.keyName] === item[state.keyName],
+        ),
+        1,
+      );
+    } else {
+      state.selectedIds.push(item[state.keyName]);
+      state.selectedList.push(item);
+    }
+  } else {
+    if (state.selectedIds.includes(item[state.keyName])) {
+      state.selectedIds = [];
+      state.selectedList = [];
+    } else {
+      state.selectedIds = [item[state.keyName]];
+    }
+  }
+};
+
+const handelPageChange = async (pageIndex: number) => {
+  setState({ loading: true });
+  state.page.pageIndex = pageIndex;
+  await fetch();
+  setState({ loading: false });
+};
+</script>
+
+<template>
+  <Modal :title="`选择${title}`" class="w-[1000px]">
+    <div class="relative h-full p-4">
+      <div class="card-box text-card-foreground border-border w-full border">
+        <div class="flex flex-col gap-y-1.5 border-b p-2">
+          <h3 class="text-xl font-semibold tracking-tight">{{ title }}列表</h3>
+        </div>
+        <div class="p-2">
+          <Form class="mt-4" />
+        </div>
+        <div class="h-[336px]">
+          <div class="grid grid-cols-3 gap-4 p-2">
+            <SelectCardItem
+              v-for="(item, index) in cardData"
+              :key="index"
+              :checked="state.selectedIds.includes(item[state.keyName])"
+              :config="state.config"
+              :disabled="
+                state.disabledIds &&
+                state.disabledIds.includes(item[state.keyName] as never)
+                  ? true
+                  : false
+              "
+              :field-names="state.fieldNames"
+              :model="item"
+              @click="hancelClick(item)"
+            >
+              <template #check>
+                <Checkbox
+                  :checked="state.selectedIds.includes(item[state.keyName])"
+                  size="small"
+                />
+              </template>
+            </SelectCardItem>
+          </div>
+        </div>
+        <div class="my-[16px] flex w-full justify-end">
+          <Pagination
+            v-model:current="state.page.pageIndex"
+            :page-size="state.page.pageSize"
+            :total="state.page.total"
+            show-less-items
+            show-quick-jumper
+            size="small"
+            @change="handelPageChange"
+          />
+        </div>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<style lang="less" scoped></style>

+ 2 - 0
apps/baicai-cms/src/components/table-action/index.ts

@@ -0,0 +1,2 @@
+export { default as TableAction } from './src/table-action.vue';
+export type * from './src/types';

+ 263 - 0
apps/baicai-cms/src/components/table-action/src/table-action.vue

@@ -0,0 +1,263 @@
+<script setup lang="ts">
+import type { ButtonType } from 'ant-design-vue/es/button';
+
+import type { PropType } from 'vue';
+
+import type { ActionItem, PopConfirm } from './types';
+
+import { computed, toRaw } from 'vue';
+
+import { useAccess } from '@vben/access';
+import { isBoolean, isFunction } from '@vben/utils';
+
+import { Button, Dropdown, Menu, Popconfirm, Space } from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+
+const props = defineProps({
+  actions: {
+    type: Array as PropType<ActionItem[]>,
+    default() {
+      return [];
+    },
+  },
+  dropDownActions: {
+    type: Array as PropType<ActionItem[]>,
+    default() {
+      return [];
+    },
+  },
+  divider: {
+    type: Boolean,
+    default: true,
+  },
+});
+
+const MenuItem = Menu.Item;
+
+const { hasAccessByCodes } = useAccess();
+function isIfShow(action: ActionItem): boolean {
+  const ifShow = action.ifShow;
+
+  let isIfShow = true;
+
+  if (isBoolean(ifShow)) {
+    isIfShow = ifShow;
+  }
+  if (isFunction(ifShow)) {
+    isIfShow = ifShow(action);
+  }
+  return isIfShow;
+}
+
+function isIfDisabled(action: ActionItem): boolean {
+  if (action.authDisabled === true && (action.auth || []).length > 0) {
+    return !hasAccessByCodes(action.auth || []);
+  }
+
+  if (action.dynamicDisabled) {
+    const ifDynamicDisabled = action.dynamicDisabled;
+
+    let isIfDynamicDisabled = false;
+    if (isBoolean(ifDynamicDisabled)) {
+      isIfDynamicDisabled = ifDynamicDisabled;
+    }
+
+    if (isFunction(ifDynamicDisabled)) {
+      isIfDynamicDisabled = ifDynamicDisabled(action);
+    }
+    if (isIfDynamicDisabled === true) {
+      return true;
+    }
+  }
+  const ifDisabled = action.disabled;
+
+  let isIfDisabled = false;
+  if (isBoolean(ifDisabled)) {
+    isIfDisabled = ifDisabled;
+  }
+  return isIfDisabled;
+}
+
+const getActions = computed(() => {
+  return (toRaw(props.actions) || [])
+    .filter((action) => {
+      return (
+        ((hasAccessByCodes(action.auth || []) ||
+          (action.auth || []).length === 0) &&
+          isIfShow(action)) ||
+        action.authDisabled !== true
+      );
+    })
+    .map((action) => {
+      const { popConfirm } = action;
+      return {
+        // getPopupContainer: document.body,
+        type: 'link' as ButtonType,
+        ...action,
+        ...popConfirm,
+        onConfirm: popConfirm?.confirm,
+        onCancel: popConfirm?.cancel,
+        enable: !!popConfirm,
+        disabled: isIfDisabled(action),
+      };
+    });
+});
+const getDropdownList = computed((): any[] => {
+  return (toRaw(props.dropDownActions) || [])
+    .filter((action) => {
+      return (
+        ((hasAccessByCodes(action.auth || []) ||
+          (action.auth || []).length === 0) &&
+          isIfShow(action)) ||
+        action.authDisabled !== true
+      );
+    })
+    .map((action, index) => {
+      const { label, popConfirm } = action;
+      return {
+        ...action,
+        ...popConfirm,
+        onConfirm: popConfirm?.confirm,
+        onCancel: popConfirm?.cancel,
+        text: label,
+        divider:
+          index < props.dropDownActions.length - 1 ? props.divider : false,
+        disabled: isIfDisabled(action),
+      };
+    });
+});
+const getPopConfirmProps = (attrs: PopConfirm) => {
+  const originAttrs: any = attrs;
+  delete originAttrs.icon;
+  if (attrs.confirm && isFunction(attrs.confirm)) {
+    originAttrs.onConfirm = attrs.confirm;
+    delete originAttrs.confirm;
+  }
+  if (attrs.cancel && isFunction(attrs.cancel)) {
+    originAttrs.onCancel = attrs.cancel;
+    delete originAttrs.cancel;
+  }
+  return originAttrs;
+};
+const getButtonProps = (action: ActionItem) => {
+  const res = {
+    type: action.type || 'primary',
+    size: action?.size || 'small',
+    ...action,
+  };
+  delete res.icon;
+  return res;
+};
+// const handleMenuClick = (e: any) => {
+//   const action = getDropdownList.value[e.key];
+//   if (action.onClick && isFunction(action.onClick)) {
+//     action.onClick();
+//   }
+// };
+</script>
+
+<template>
+  <div class="m-table-action flex items-center">
+    <Space :size="2">
+      <template v-for="(action, index) in getActions" :key="index">
+        <Popconfirm
+          v-if="action.popConfirm"
+          v-bind="getPopConfirmProps(action.popConfirm)"
+        >
+          <template v-if="action.popConfirm.icon" #icon>
+            <Icon :icon="action.popConfirm.icon" />
+          </template>
+          <Button v-bind="getButtonProps(action)">
+            <template v-if="action.icon" #icon>
+              <Icon :icon="action.icon" />
+            </template>
+            {{ action.label }}
+          </Button>
+        </Popconfirm>
+        <Button v-else v-bind="getButtonProps(action)" @click="action.onClick">
+          <template v-if="action.icon" #icon>
+            <Icon :icon="action.icon" />
+          </template>
+          {{ action.label }}
+        </Button>
+      </template>
+    </Space>
+
+    <Dropdown v-if="getDropdownList.length > 0" :trigger="['hover']">
+      <slot name="more">
+        <Button size="small" type="link">
+          <template #icon>
+            <Icon class="icon-more size-5" icon="ic:twotone-more-horiz" />
+          </template>
+        </Button>
+      </slot>
+      <template #overlay>
+        <Menu>
+          <MenuItem v-for="(action, index) in getDropdownList" :key="index">
+            <template v-if="action.popConfirm">
+              <Popconfirm v-bind="getPopConfirmProps(action.popConfirm)">
+                <template v-if="action.popConfirm.icon" #icon>
+                  <Icon :icon="action.popConfirm.icon" />
+                </template>
+                <div
+                  :class="
+                    action.disabled === true
+                      ? 'cursor-not-allowed text-gray-300'
+                      : ''
+                  "
+                >
+                  <Icon v-if="action.icon" :icon="action.icon" />
+                  <span class="ml-1">{{ action.text }}</span>
+                </div>
+              </Popconfirm>
+            </template>
+            <template v-else>
+              <Button v-bind="getButtonProps(action)">
+                <template v-if="action.icon" #icon>
+                  <Icon :icon="action.icon" />
+                </template>
+                {{ action.label }}
+              </Button>
+              <!-- <div
+                :class="
+                  action.disabled === true
+                    ? 'cursor-not-allowed text-gray-300'
+                    : ''
+                "
+              >
+                <Icon v-if="action.icon" :icon="action.icon" />
+                {{ action.label }}
+              </div> -->
+            </template>
+          </MenuItem>
+        </Menu>
+      </template>
+    </Dropdown>
+  </div>
+</template>
+<style lang="less">
+/** 修复 iconify 位置问题 **/
+.m-table-action {
+  .ant-btn > .iconify + span,
+  .ant-btn > span + .iconify {
+    margin-inline-start: 8px;
+  }
+
+  .ant-btn > .iconify {
+    display: inline-flex;
+    align-items: center;
+    width: 1em;
+    height: 1em;
+    font-style: normal;
+    line-height: 0;
+    color: inherit;
+    text-align: center;
+    text-transform: none;
+    vertical-align: -0.125em;
+    text-rendering: optimizelegibility;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+  }
+}
+</style>

+ 28 - 0
apps/baicai-cms/src/components/table-action/src/types.d.ts

@@ -0,0 +1,28 @@
+import { ButtonProps } from 'ant-design-vue/es/button/buttonTypes';
+import { TooltipProps } from 'ant-design-vue/es/tooltip/Tooltip';
+
+export interface PopConfirm {
+  title: string;
+  okText?: string;
+  cancelText?: string;
+  confirm: Fn;
+  cancel?: Fn;
+  icon?: string;
+  disabled?: boolean;
+}
+export interface ActionItem extends ButtonProps {
+  onClick?: Fn;
+  label?: ((row: Record<string, any>) => string) | string;
+  color?: 'error' | 'success' | 'warning';
+  icon?: string;
+  popConfirm?: PopConfirm;
+  disabled?: boolean;
+  dynamicDisabled?: ((action: ActionItem) => boolean) | boolean;
+  divider?: boolean;
+  // 权限编码控制是否显示
+  auth?: string[];
+  // 业务控制是否显示
+  ifShow?: ((action: ActionItem) => boolean) | boolean;
+  tooltip?: string | TooltipProps;
+  [key: string]: any;
+}

+ 25 - 0
apps/baicai-cms/src/layouts/auth.vue

@@ -0,0 +1,25 @@
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+import { AuthPageLayout } from '@vben/layouts';
+import { preferences } from '@vben/preferences';
+
+import { $t } from '#/locales';
+
+const appName = computed(() => preferences.app.name);
+const logo = computed(() => preferences.logo.source);
+const clickLogo = () => {};
+</script>
+
+<template>
+  <AuthPageLayout
+    :app-name="appName"
+    :logo="logo"
+    :page-description="$t('authentication.pageDesc')"
+    :page-title="$t('authentication.pageTitle')"
+    :click-logo="clickLogo"
+  >
+    <!-- 自定义工具栏 -->
+    <!-- <template #toolbar></template> -->
+  </AuthPageLayout>
+</template>

+ 194 - 0
apps/baicai-cms/src/layouts/basic.vue

@@ -0,0 +1,194 @@
+<script lang="ts" setup>
+import type { NotificationItem } from '@vben/layouts';
+
+import { computed, onBeforeMount, ref, watch } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
+import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
+import { useWatermark } from '@vben/hooks';
+import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
+import {
+  BasicLayout,
+  LockScreen,
+  Notification,
+  UserDropdown,
+} from '@vben/layouts';
+import { preferences } from '@vben/preferences';
+import { useAccessStore, useTabbarStore, useUserStore } from '@vben/stores';
+import { openWindow } from '@vben/utils';
+
+import { $t } from '#/locales';
+import { useAuthStore } from '#/store';
+import LoginForm from '#/views/_core/authentication/login.vue';
+
+const { setMenuList } = useTabbarStore();
+setMenuList([
+  'close',
+  'affix',
+  'maximize',
+  'reload',
+  'open-in-new-window',
+  'close-left',
+  'close-right',
+  'close-other',
+  'close-all',
+]);
+
+const notifications = ref<NotificationItem[]>([
+  {
+    avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
+    date: '3小时前',
+    isRead: true,
+    message: '描述信息描述信息描述信息',
+    title: '收到了 14 份新周报',
+  },
+  {
+    avatar: 'https://avatar.vercel.sh/1',
+    date: '刚刚',
+    isRead: false,
+    message: '描述信息描述信息描述信息',
+    title: '朱偏右 回复了你',
+  },
+  {
+    avatar: 'https://avatar.vercel.sh/1',
+    date: '2024-01-01',
+    isRead: false,
+    message: '描述信息描述信息描述信息',
+    title: '曲丽丽 评论了你',
+  },
+  {
+    avatar: 'https://avatar.vercel.sh/satori',
+    date: '1天前',
+    isRead: false,
+    message: '描述信息描述信息描述信息',
+    title: '代办提醒',
+  },
+]);
+
+const router = useRouter();
+const userStore = useUserStore();
+const authStore = useAuthStore();
+const accessStore = useAccessStore();
+const { destroyWatermark, updateWatermark } = useWatermark();
+const showDot = computed(() =>
+  notifications.value.some((item) => !item.isRead),
+);
+
+const menus = computed(() => [
+  {
+    handler: () => {
+      openWindow(VBEN_DOC_URL, {
+        target: '_blank',
+      });
+    },
+    icon: BookOpenText,
+    text: $t('ui.widgets.document'),
+  },
+  {
+    handler: () => {
+      router.push({
+        name: 'personal',
+      });
+    },
+    icon: 'fa-solid:user-tag',
+    text: '个人中心',
+  },
+  {
+    handler: () => {
+      openWindow(VBEN_GITHUB_URL, {
+        target: '_blank',
+      });
+    },
+    icon: MdiGithub,
+    text: 'GitHub',
+  },
+  {
+    handler: () => {
+      openWindow(`${VBEN_GITHUB_URL}/issues`, {
+        target: '_blank',
+      });
+    },
+    icon: CircleHelp,
+    text: $t('ui.widgets.qa'),
+  },
+]);
+
+const avatar = computed(() => {
+  return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
+});
+
+async function handleLogout() {
+  await authStore.logout(false);
+}
+
+function handleNoticeClear() {
+  notifications.value = [];
+}
+
+function handleMakeAll() {
+  notifications.value.forEach((item) => (item.isRead = true));
+}
+
+function handleClickLogo() {}
+
+watch(
+  () => preferences.app.watermark,
+  async (enable) => {
+    if (enable) {
+      await updateWatermark({
+        content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
+      });
+    } else {
+      destroyWatermark();
+    }
+  },
+  {
+    immediate: true,
+  },
+);
+
+onBeforeMount(() => {
+  if (preferences.app.watermark) {
+    destroyWatermark();
+  }
+});
+</script>
+
+<template>
+  <BasicLayout
+    @clear-preferences-and-logout="handleLogout"
+    @click-logo="handleClickLogo"
+  >
+    <template #user-dropdown>
+      <UserDropdown
+        :avatar
+        :menus
+        :text="userStore.userInfo?.realName"
+        description="ann.vben@gmail.com"
+        tag-text="Pro"
+        trigger="both"
+        @logout="handleLogout"
+      />
+    </template>
+    <template #notification>
+      <Notification
+        :dot="showDot"
+        :notifications="notifications"
+        @clear="handleNoticeClear"
+        @make-all="handleMakeAll"
+      />
+    </template>
+    <template #extra>
+      <AuthenticationLoginExpiredModal
+        v-model:open="accessStore.loginExpired"
+        :avatar
+      >
+        <LoginForm />
+      </AuthenticationLoginExpiredModal>
+    </template>
+    <template #lock-screen>
+      <LockScreen :avatar @to-login="handleLogout" />
+    </template>
+  </BasicLayout>
+</template>

+ 1 - 0
apps/baicai-cms/src/layouts/empty/index.ts

@@ -0,0 +1 @@
+export { default as EmptyLayout } from './layout.vue';

+ 40 - 0
apps/baicai-cms/src/layouts/empty/layout.vue

@@ -0,0 +1,40 @@
+<script setup lang="ts">
+import { usePreferences } from '@vben/preferences';
+
+interface Props {
+  header?: boolean;
+  clickLogo?: () => void;
+}
+
+withDefaults(defineProps<Props>(), {
+  header: false,
+  clickLogo: () => {},
+});
+
+const { theme } = usePreferences();
+</script>
+
+<template>
+  <div class="relative flex min-h-full w-full">
+    <div
+      :class="[theme]"
+      class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
+    >
+      <slot name="header"></slot>
+      <div class="relative h-full">
+        <RouterView v-slot="{ Component, route }">
+          <Transition appear mode="out-in">
+            <KeepAlive>
+              <component
+                :is="Component"
+                :key="route.fullPath"
+                class="enter-x w-full"
+              />
+            </KeepAlive>
+          </Transition>
+        </RouterView>
+      </div>
+      <slot name="footer"></slot>
+    </div>
+  </div>
+</template>

+ 10 - 0
apps/baicai-cms/src/layouts/index.ts

@@ -0,0 +1,10 @@
+const BasicLayout = () => import('./basic.vue');
+const AuthPageLayout = () => import('./auth.vue');
+
+const PageLayout = () => import('./page').then((m) => m.PageLayout);
+
+const EmptyLayout = () => import('./empty').then((m) => m.EmptyLayout);
+
+const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
+
+export { AuthPageLayout, BasicLayout, EmptyLayout, IFrameView, PageLayout };

+ 1 - 0
apps/baicai-cms/src/layouts/page/index.ts

@@ -0,0 +1 @@
+export { default as PageLayout } from './layout.vue';

+ 51 - 0
apps/baicai-cms/src/layouts/page/layout.vue

@@ -0,0 +1,51 @@
+<script setup lang="ts">
+import type { MenuRecordRaw } from '@vben/types';
+
+import { computed } from 'vue';
+
+import { preferences, usePreferences } from '@vben/preferences';
+import { useAccessStore } from '@vben/stores';
+import { cloneDeep, mapTree } from '@vben/utils';
+
+import { EmptyLayout } from '../empty';
+import { LayoutMenu } from './menu';
+import { useMixedMenu } from './menu/use-mixed-menu';
+
+const accessStore = useAccessStore();
+const { handleMenuSelect, headerActive } = useMixedMenu();
+
+const menus = computed(() => accessStore.accessMenus);
+const { theme } = usePreferences();
+
+const isMenuRounded = computed(() => {
+  return preferences.navigation.styleType === 'rounded';
+});
+function wrapperMenus(menus: MenuRecordRaw[], deep: boolean = true) {
+  return deep
+    ? mapTree(menus, (item) => {
+        return { ...cloneDeep(item), name: item.name };
+      })
+    : menus.map((item) => {
+        return { ...cloneDeep(item), name: item.name };
+      });
+}
+</script>
+
+<template>
+  <EmptyLayout>
+    <template #header>
+      <LayoutMenu
+        :default-active="headerActive"
+        :menus="wrapperMenus(menus)"
+        :rounded="isMenuRounded"
+        :theme="theme"
+        class="w-full"
+        mode="horizontal"
+        @select="handleMenuSelect"
+      />
+    </template>
+    <template #footer>
+      <div>footer</div>
+    </template>
+  </EmptyLayout>
+</template>

+ 1 - 0
apps/baicai-cms/src/layouts/page/menu/index.ts

@@ -0,0 +1 @@
+export { default as LayoutMenu } from './menu.vue';

+ 45 - 0
apps/baicai-cms/src/layouts/page/menu/menu.vue

@@ -0,0 +1,45 @@
+<script lang="ts" setup>
+import type { MenuRecordRaw } from '@vben/types';
+
+import type { MenuProps } from '@vben-core/menu-ui';
+
+import { Menu } from '@vben-core/menu-ui';
+
+interface Props extends MenuProps {
+  menus?: MenuRecordRaw[];
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  accordion: true,
+  menus: () => [],
+});
+
+const emit = defineEmits<{
+  open: [string, string[]];
+  select: [string, string?];
+}>();
+
+function handleMenuSelect(key: string) {
+  emit('select', key, props.mode);
+}
+
+function handleMenuOpen(key: string, path: string[]) {
+  emit('open', key, path);
+}
+</script>
+
+<template>
+  <Menu
+    :accordion="accordion"
+    :collapse="collapse"
+    :collapse-show-title="collapseShowTitle"
+    :default-active="defaultActive"
+    :menus="menus"
+    :mode="mode"
+    :rounded="rounded"
+    scroll-to-active
+    :theme="theme"
+    @open="handleMenuOpen"
+    @select="handleMenuSelect"
+  />
+</template>

+ 169 - 0
apps/baicai-cms/src/layouts/page/menu/use-mixed-menu.ts

@@ -0,0 +1,169 @@
+import type { MenuRecordRaw } from '@vben/types';
+
+import { computed, onBeforeMount, ref, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { preferences, usePreferences } from '@vben/preferences';
+import { useAccessStore } from '@vben/stores';
+import { findRootMenuByPath } from '@vben/utils';
+
+import { useNavigation } from './use-navigation';
+
+function useMixedMenu() {
+  const { navigation, willOpenedByWindow } = useNavigation();
+  const accessStore = useAccessStore();
+  const route = useRoute();
+  const splitSideMenus = ref<MenuRecordRaw[]>([]);
+  const rootMenuPath = ref<string>('');
+  const mixedRootMenuPath = ref<string>('');
+  const mixExtraMenus = ref<MenuRecordRaw[]>([]);
+  /** 记录当前顶级菜单下哪个子菜单最后激活 */
+  const defaultSubMap = new Map<string, string>();
+  const { isMixedNav, isHeaderMixedNav } = usePreferences();
+
+  const needSplit = computed(
+    () =>
+      (preferences.navigation.split && isMixedNav.value) ||
+      isHeaderMixedNav.value,
+  );
+
+  const sidebarVisible = computed(() => {
+    const enableSidebar = preferences.sidebar.enable;
+    if (needSplit.value) {
+      return enableSidebar && splitSideMenus.value.length > 0;
+    }
+    return enableSidebar;
+  });
+  const menus = computed(() => accessStore.accessMenus);
+
+  /**
+   * 头部菜单
+   */
+  const headerMenus = computed(() => {
+    if (!needSplit.value) {
+      return menus.value;
+    }
+    return menus.value.map((item) => {
+      return {
+        ...item,
+        children: [],
+      };
+    });
+  });
+
+  /**
+   * 侧边菜单
+   */
+  const sidebarMenus = computed(() => {
+    return needSplit.value ? splitSideMenus.value : menus.value;
+  });
+
+  const mixHeaderMenus = computed(() => {
+    return isHeaderMixedNav.value ? sidebarMenus.value : headerMenus.value;
+  });
+
+  /**
+   * 侧边菜单激活路径
+   */
+  const sidebarActive = computed(() => {
+    return (route?.meta?.activePath as string) ?? route.path;
+  });
+
+  /**
+   * 头部菜单激活路径
+   */
+  const headerActive = computed(() => {
+    if (!needSplit.value) {
+      return route.meta?.activePath ?? route.path;
+    }
+    return rootMenuPath.value;
+  });
+
+  /**
+   * 菜单点击事件处理
+   * @param key 菜单路径
+   * @param mode 菜单模式
+   */
+  const handleMenuSelect = (key: string, mode?: string) => {
+    if (!needSplit.value || mode === 'vertical') {
+      navigation(key);
+      return;
+    }
+    const rootMenu = menus.value.find((item) => item.path === key);
+    const _splitSideMenus = rootMenu?.children ?? [];
+
+    if (!willOpenedByWindow(key)) {
+      rootMenuPath.value = rootMenu?.path ?? '';
+      splitSideMenus.value = _splitSideMenus;
+    }
+
+    if (_splitSideMenus.length === 0) {
+      navigation(key);
+    } else if (rootMenu && preferences.sidebar.autoActivateChild) {
+      navigation(
+        defaultSubMap.has(rootMenu.path)
+          ? (defaultSubMap.get(rootMenu.path) as string)
+          : rootMenu.path,
+      );
+    }
+  };
+
+  /**
+   * 侧边菜单展开事件
+   * @param key 路由路径
+   * @param parentsPath 父级路径
+   */
+  const handleMenuOpen = (key: string, parentsPath: string[]) => {
+    if (parentsPath.length <= 1 && preferences.sidebar.autoActivateChild) {
+      navigation(
+        defaultSubMap.has(key) ? (defaultSubMap.get(key) as string) : key,
+      );
+    }
+  };
+
+  /**
+   * 计算侧边菜单
+   * @param path 路由路径
+   */
+  function calcSideMenus(path: string = route.path) {
+    let { rootMenu } = findRootMenuByPath(menus.value, path);
+    if (!rootMenu) {
+      rootMenu = menus.value.find((item) => item.path === path);
+    }
+    const result = findRootMenuByPath(rootMenu?.children || [], path, 1);
+    mixedRootMenuPath.value = result.rootMenuPath ?? '';
+    mixExtraMenus.value = result.rootMenu?.children ?? [];
+    rootMenuPath.value = rootMenu?.path ?? '';
+    splitSideMenus.value = rootMenu?.children ?? [];
+  }
+
+  watch(
+    () => route.path,
+    (path) => {
+      const currentPath = (route?.meta?.activePath as string) ?? path;
+      calcSideMenus(currentPath);
+      if (rootMenuPath.value)
+        defaultSubMap.set(rootMenuPath.value, currentPath);
+    },
+    { immediate: true },
+  );
+
+  // 初始化计算侧边菜单
+  onBeforeMount(() => {
+    calcSideMenus(route.meta?.activePath || route.path);
+  });
+
+  return {
+    handleMenuSelect,
+    handleMenuOpen,
+    headerActive,
+    headerMenus,
+    sidebarActive,
+    sidebarMenus,
+    mixHeaderMenus,
+    mixExtraMenus,
+    sidebarVisible,
+  };
+}
+
+export { useMixedMenu };

+ 63 - 0
apps/baicai-cms/src/layouts/page/menu/use-navigation.ts

@@ -0,0 +1,63 @@
+import type { RouteRecordNormalized } from 'vue-router';
+
+import { useRouter } from 'vue-router';
+
+import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
+
+function useNavigation() {
+  const router = useRouter();
+  const routeMetaMap = new Map<string, RouteRecordNormalized>();
+
+  // 初始化路由映射
+  const initRouteMetaMap = () => {
+    const routes = router.getRoutes();
+    routes.forEach((route) => {
+      routeMetaMap.set(route.path, route);
+    });
+  };
+
+  initRouteMetaMap();
+
+  // 监听路由变化
+  router.afterEach(() => {
+    initRouteMetaMap();
+  });
+
+  // 检查是否应该在新窗口打开
+  const shouldOpenInNewWindow = (path: string): boolean => {
+    if (isHttpUrl(path)) {
+      return true;
+    }
+    const route = routeMetaMap.get(path);
+    return route?.meta?.openInNewWindow ?? false;
+  };
+
+  const navigation = async (path: string) => {
+    try {
+      const route = routeMetaMap.get(path);
+      const { openInNewWindow = false, query = {} } = route?.meta ?? {};
+
+      if (isHttpUrl(path)) {
+        openWindow(path, { target: '_blank' });
+      } else if (openInNewWindow) {
+        openRouteInNewWindow(path);
+      } else {
+        await router.push({
+          path,
+          query,
+        });
+      }
+    } catch (error) {
+      console.error('Navigation failed:', error);
+      throw error;
+    }
+  };
+
+  const willOpenedByWindow = (path: string) => {
+    return shouldOpenInNewWindow(path);
+  };
+
+  return { navigation, willOpenedByWindow };
+}
+
+export { useNavigation };

+ 3 - 0
apps/baicai-cms/src/locales/README.md

@@ -0,0 +1,3 @@
+# locale
+
+每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。

+ 102 - 0
apps/baicai-cms/src/locales/index.ts

@@ -0,0 +1,102 @@
+import type { Locale } from 'ant-design-vue/es/locale';
+
+import type { App } from 'vue';
+
+import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
+
+import { ref } from 'vue';
+
+import {
+  $t,
+  setupI18n as coreSetup,
+  loadLocalesMapFromDir,
+} from '@vben/locales';
+import { preferences } from '@vben/preferences';
+
+import antdEnLocale from 'ant-design-vue/es/locale/en_US';
+import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN';
+import dayjs from 'dayjs';
+
+const antdLocale = ref<Locale>(antdDefaultLocale);
+
+const modules = import.meta.glob('./langs/**/*.json');
+
+const localesMap = loadLocalesMapFromDir(
+  /\.\/langs\/([^/]+)\/(.*)\.json$/,
+  modules,
+);
+/**
+ * 加载应用特有的语言包
+ * 这里也可以改造为从服务端获取翻译数据
+ * @param lang
+ */
+async function loadMessages(lang: SupportedLanguagesType) {
+  const [appLocaleMessages] = await Promise.all([
+    localesMap[lang]?.(),
+    loadThirdPartyMessage(lang),
+  ]);
+  return appLocaleMessages?.default;
+}
+
+/**
+ * 加载第三方组件库的语言包
+ * @param lang
+ */
+async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
+  await Promise.all([loadAntdLocale(lang), loadDayjsLocale(lang)]);
+}
+
+/**
+ * 加载dayjs的语言包
+ * @param lang
+ */
+async function loadDayjsLocale(lang: SupportedLanguagesType) {
+  let locale;
+  switch (lang) {
+    case 'en-US': {
+      locale = await import('dayjs/locale/en');
+      break;
+    }
+    case 'zh-CN': {
+      locale = await import('dayjs/locale/zh-cn');
+      break;
+    }
+    // 默认使用英语
+    default: {
+      locale = await import('dayjs/locale/en');
+    }
+  }
+  if (locale) {
+    dayjs.locale(locale);
+  } else {
+    console.error(`Failed to load dayjs locale for ${lang}`);
+  }
+}
+
+/**
+ * 加载antd的语言包
+ * @param lang
+ */
+async function loadAntdLocale(lang: SupportedLanguagesType) {
+  switch (lang) {
+    case 'en-US': {
+      antdLocale.value = antdEnLocale;
+      break;
+    }
+    case 'zh-CN': {
+      antdLocale.value = antdDefaultLocale;
+      break;
+    }
+  }
+}
+
+async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
+  await coreSetup(app, {
+    defaultLocale: preferences.app.locale,
+    loadMessages,
+    missingWarn: !import.meta.env.PROD,
+    ...options,
+  });
+}
+
+export { $t, antdLocale, setupI18n };

+ 125 - 0
apps/baicai-cms/src/locales/langs/en-US.json

@@ -0,0 +1,125 @@
+{
+  "page": {
+    "demos": {
+      "title": "Demos",
+      "access": {
+        "frontendPermissions": "Frontend Permissions",
+        "backendPermissions": "Backend Permissions",
+        "pageAccess": "Page Access",
+        "buttonControl": "Button Control",
+        "menuVisible403": "Menu Visible(403)",
+        "superVisible": "Visible to Super",
+        "adminVisible": "Visible to Admin",
+        "userVisible": "Visible to User"
+      },
+      "nested": {
+        "title": "Nested Menu",
+        "menu1": "Menu 1",
+        "menu2": "Menu 2",
+        "menu2_1": "Menu 2-1",
+        "menu3": "Menu 3",
+        "menu3_1": "Menu 3-1",
+        "menu3_2": "Menu 3-2",
+        "menu3_2_1": "Menu 3-2-1"
+      },
+      "outside": {
+        "title": "External Pages",
+        "embedded": "Embedded",
+        "externalLink": "External Link"
+      },
+      "badge": {
+        "title": "Menu Badge",
+        "dot": "Dot Badge",
+        "text": "Text Badge",
+        "color": "Badge Color"
+      },
+      "activeIcon": {
+        "title": "Active Menu Icon",
+        "children": "Children Active Icon"
+      },
+      "fallback": {
+        "title": "Fallback Page"
+      },
+      "features": {
+        "title": "Features",
+        "hideChildrenInMenu": "Hide Menu Children",
+        "loginExpired": "Login Expired",
+        "icons": "Icons",
+        "watermark": "Watermark",
+        "tabs": "Tabs",
+        "tabDetail": "Tab Detail Page",
+        "fullScreen": {
+          "title": "FullScreen"
+        },
+        "clipboard": "Clipboard"
+      },
+      "breadcrumb": {
+        "navigation": "Breadcrumb Navigation",
+        "lateral": "Lateral Mode",
+        "lateralDetail": "Lateral Mode Detail",
+        "level": "Level Mode",
+        "levelDetail": "Level Mode Detail"
+      }
+    },
+    "examples": {
+      "title": "Examples",
+      "modal": {
+        "title": "Modal"
+      },
+      "drawer": {
+        "title": "Drawer"
+      },
+      "ellipsis": {
+        "title": "EllipsisText"
+      },
+      "form": {
+        "title": "Form",
+        "basic": "Basic Form",
+        "query": "Query Form",
+        "rules": "Form Rules",
+        "dynamic": "Dynamic Form",
+        "custom": "Custom Component",
+        "api": "Api",
+        "merge": "Merge Form"
+      },
+      "vxeTable": {
+        "title": "Vxe Table",
+        "basic": "Basic Table",
+        "remote": "Remote Load",
+        "tree": "Tree Table",
+        "fixed": "Fixed Header/Column",
+        "virtual": "Virtual Scroll",
+        "editCell": "Edit Cell",
+        "editRow": "Edit Row",
+        "custom-cell": "Custom Cell",
+        "form": "Form Table"
+      },
+      "captcha": {
+        "title": "Captcha",
+        "pointSelection": "Point Selection Captcha",
+        "sliderCaptcha": "Slider Captcha",
+        "sliderRotateCaptcha": "Rotate Captcha",
+        "captchaCardTitle": "Please complete the security verification",
+        "pageDescription": "Verify user identity by clicking on specific locations in the image.",
+        "pageTitle": "Captcha Component Example",
+        "basic": "Basic Usage",
+        "titlePlaceholder": "Captcha Title Text",
+        "captchaImageUrlPlaceholder": "Captcha Image (supports img tag src attribute value)",
+        "hintImage": "Hint Image",
+        "hintText": "Hint Text",
+        "hintImagePlaceholder": "Hint Image (supports img tag src attribute value)",
+        "hintTextPlaceholder": "Hint Text",
+        "showConfirm": "Show Confirm",
+        "hideConfirm": "Hide Confirm",
+        "widthPlaceholder": "Captcha Image Width Default 300px",
+        "heightPlaceholder": "Captcha Image Height Default 220px",
+        "paddingXPlaceholder": "Horizontal Padding Default 12px",
+        "paddingYPlaceholder": "Vertical Padding Default 16px",
+        "index": "Index:",
+        "timestamp": "Timestamp:",
+        "x": "x:",
+        "y": "y:"
+      }
+    }
+  }
+}

+ 70 - 0
apps/baicai-cms/src/locales/langs/en-US/demos.json

@@ -0,0 +1,70 @@
+{
+  "title": "Demos",
+  "access": {
+    "frontendPermissions": "Frontend Permissions",
+    "backendPermissions": "Backend Permissions",
+    "pageAccess": "Page Access",
+    "buttonControl": "Button Control",
+    "menuVisible403": "Menu Visible(403)",
+    "superVisible": "Visible to Super",
+    "adminVisible": "Visible to Admin",
+    "userVisible": "Visible to User"
+  },
+  "nested": {
+    "title": "Nested Menu",
+    "menu1": "Menu 1",
+    "menu2": "Menu 2",
+    "menu2_1": "Menu 2-1",
+    "menu3": "Menu 3",
+    "menu3_1": "Menu 3-1",
+    "menu3_2": "Menu 3-2",
+    "menu3_2_1": "Menu 3-2-1"
+  },
+  "outside": {
+    "title": "External Pages",
+    "embedded": "Embedded",
+    "externalLink": "External Link"
+  },
+  "badge": {
+    "title": "Menu Badge",
+    "dot": "Dot Badge",
+    "text": "Text Badge",
+    "color": "Badge Color"
+  },
+  "activeIcon": {
+    "title": "Active Menu Icon",
+    "children": "Children Active Icon"
+  },
+  "fallback": {
+    "title": "Fallback Page"
+  },
+  "features": {
+    "title": "Features",
+    "hideChildrenInMenu": "Hide Menu Children",
+    "loginExpired": "Login Expired",
+    "icons": "Icons",
+    "watermark": "Watermark",
+    "tabs": "Tabs",
+    "tabDetail": "Tab Detail Page",
+    "fullScreen": "FullScreen",
+    "clipboard": "Clipboard",
+    "menuWithQuery": "Menu With Query",
+    "openInNewWindow": "Open in New Window",
+    "fileDownload": "File Download"
+  },
+  "breadcrumb": {
+    "navigation": "Breadcrumb Navigation",
+    "lateral": "Lateral Mode",
+    "lateralDetail": "Lateral Mode Detail",
+    "level": "Level Mode",
+    "levelDetail": "Level Mode Detail"
+  },
+  "vben": {
+    "title": "Project",
+    "about": "About",
+    "document": "Document",
+    "antdv": "Ant Design Vue Version",
+    "naive-ui": "Naive UI Version",
+    "element-plus": "Element Plus Version"
+  }
+}

+ 70 - 0
apps/baicai-cms/src/locales/langs/en-US/examples.json

@@ -0,0 +1,70 @@
+{
+  "title": "Examples",
+  "modal": {
+    "title": "Modal"
+  },
+  "drawer": {
+    "title": "Drawer"
+  },
+  "ellipsis": {
+    "title": "EllipsisText"
+  },
+  "form": {
+    "title": "Form",
+    "basic": "Basic Form",
+    "layout": "Custom Layout",
+    "query": "Query Form",
+    "rules": "Form Rules",
+    "dynamic": "Dynamic Form",
+    "custom": "Custom Component",
+    "api": "Api",
+    "merge": "Merge Form"
+  },
+  "vxeTable": {
+    "title": "Vxe Table",
+    "basic": "Basic Table",
+    "remote": "Remote Load",
+    "tree": "Tree Table",
+    "fixed": "Fixed Header/Column",
+    "virtual": "Virtual Scroll",
+    "editCell": "Edit Cell",
+    "editRow": "Edit Row",
+    "custom-cell": "Custom Cell",
+    "form": "Form Table"
+  },
+  "captcha": {
+    "title": "Captcha",
+    "pointSelection": "Point Selection Captcha",
+    "sliderCaptcha": "Slider Captcha",
+    "sliderRotateCaptcha": "Rotate Captcha",
+    "captchaCardTitle": "Please complete the security verification",
+    "pageDescription": "Verify user identity by clicking on specific locations in the image.",
+    "pageTitle": "Captcha Component Example",
+    "basic": "Basic Usage",
+    "titlePlaceholder": "Captcha Title Text",
+    "captchaImageUrlPlaceholder": "Captcha Image (supports img tag src attribute value)",
+    "hintImage": "Hint Image",
+    "hintText": "Hint Text",
+    "hintImagePlaceholder": "Hint Image (supports img tag src attribute value)",
+    "hintTextPlaceholder": "Hint Text",
+    "showConfirm": "Show Confirm",
+    "hideConfirm": "Hide Confirm",
+    "widthPlaceholder": "Captcha Image Width Default 300px",
+    "heightPlaceholder": "Captcha Image Height Default 220px",
+    "paddingXPlaceholder": "Horizontal Padding Default 12px",
+    "paddingYPlaceholder": "Vertical Padding Default 16px",
+    "index": "Index:",
+    "timestamp": "Timestamp:",
+    "x": "x:",
+    "y": "y:"
+  },
+  "resize": {
+    "title": "Resize"
+  },
+  "layout": {
+    "col-page": "ColPage Layout"
+  },
+  "button-group": {
+    "title": "Button Group"
+  }
+}

+ 16 - 0
apps/baicai-cms/src/locales/langs/en-US/page.json

@@ -0,0 +1,16 @@
+{
+  "auth": {
+    "login": "Login",
+    "register": "Register",
+    "codeLogin": "Code Login",
+    "qrcodeLogin": "Qr Code Login",
+    "forgetPassword": "Forget Password",
+    "sendingCode": "SMS Code is sending...",
+    "codeSentTo": "Code has been sent to {0}"
+  },
+  "dashboard": {
+    "title": "Dashboard",
+    "analytics": "Analytics",
+    "workspace": "Workspace"
+  }
+}

+ 125 - 0
apps/baicai-cms/src/locales/langs/zh-CN.json

@@ -0,0 +1,125 @@
+{
+  "page": {
+    "demos": {
+      "title": "演示",
+      "access": {
+        "frontendPermissions": "前端权限",
+        "backendPermissions": "后端权限",
+        "pageAccess": "页面访问",
+        "buttonControl": "按钮控制",
+        "menuVisible403": "菜单可见(403)",
+        "superVisible": "Super 可见",
+        "adminVisible": "Admin 可见",
+        "userVisible": "User 可见"
+      },
+      "nested": {
+        "title": "嵌套菜单",
+        "menu1": "菜单 1",
+        "menu2": "菜单 2",
+        "menu2_1": "菜单 2-1",
+        "menu3": "菜单 3",
+        "menu3_1": "菜单 3-1",
+        "menu3_2": "菜单 3-2",
+        "menu3_2_1": "菜单 3-2-1"
+      },
+      "outside": {
+        "title": "外部页面",
+        "embedded": "内嵌",
+        "externalLink": "外链"
+      },
+      "badge": {
+        "title": "菜单徽标",
+        "dot": "点徽标",
+        "text": "文本徽标",
+        "color": "徽标颜色"
+      },
+      "activeIcon": {
+        "title": "菜单激活图标",
+        "children": "子级激活图标"
+      },
+      "fallback": {
+        "title": "缺省页"
+      },
+      "features": {
+        "title": "功能",
+        "hideChildrenInMenu": "隐藏子菜单",
+        "loginExpired": "登录过期",
+        "icons": "图标",
+        "watermark": "水印",
+        "tabs": "标签页",
+        "tabDetail": "标签详情页",
+        "fullScreen": {
+          "title": "全屏"
+        },
+        "clipboard": "剪贴板"
+      },
+      "breadcrumb": {
+        "navigation": "面包屑导航",
+        "lateral": "平级模式",
+        "level": "层级模式",
+        "levelDetail": "层级模式详情",
+        "lateralDetail": "平级模式详情"
+      }
+    },
+    "examples": {
+      "title": "示例",
+      "modal": {
+        "title": "弹窗"
+      },
+      "drawer": {
+        "title": "抽屉"
+      },
+      "ellipsis": {
+        "title": "文本省略"
+      },
+      "form": {
+        "title": "表单",
+        "basic": "基础表单",
+        "query": "查询表单",
+        "rules": "表单校验",
+        "dynamic": "动态表单",
+        "custom": "自定义组件",
+        "api": "Api",
+        "merge": "合并表单"
+      },
+      "vxeTable": {
+        "title": "Vxe 表格",
+        "basic": "基础表格",
+        "remote": "远程加载",
+        "tree": "树形表格",
+        "fixed": "固定表头/列",
+        "virtual": "虚拟滚动",
+        "editCell": "单元格编辑",
+        "editRow": "行编辑",
+        "custom-cell": "自定义单元格",
+        "form": "搜索表单"
+      },
+      "captcha": {
+        "title": "验证码",
+        "pointSelection": "点选验证",
+        "sliderCaptcha": "滑块验证",
+        "sliderRotateCaptcha": "旋转验证",
+        "captchaCardTitle": "请完成安全验证",
+        "pageDescription": "通过点击图片中的特定位置来验证用户身份。",
+        "pageTitle": "验证码组件示例",
+        "basic": "基本使用",
+        "titlePlaceholder": "验证码标题文案",
+        "captchaImageUrlPlaceholder": "验证码图片(支持img标签src属性值)",
+        "hintImage": "提示图片",
+        "hintText": "提示文本",
+        "hintImagePlaceholder": "提示图片(支持img标签src属性值)",
+        "hintTextPlaceholder": "提示文本",
+        "showConfirm": "展示确认",
+        "hideConfirm": "隐藏确认",
+        "widthPlaceholder": "验证码图片宽度 默认300px",
+        "heightPlaceholder": "验证码图片高度 默认220px",
+        "paddingXPlaceholder": "水平内边距 默认12px",
+        "paddingYPlaceholder": "垂直内边距 默认16px",
+        "index": "索引:",
+        "timestamp": "时间戳:",
+        "x": "x:",
+        "y": "y:"
+      }
+    }
+  }
+}

+ 70 - 0
apps/baicai-cms/src/locales/langs/zh-CN/demos.json

@@ -0,0 +1,70 @@
+{
+  "title": "演示",
+  "access": {
+    "frontendPermissions": "前端权限",
+    "backendPermissions": "后端权限",
+    "pageAccess": "页面访问",
+    "buttonControl": "按钮控制",
+    "menuVisible403": "菜单可见(403)",
+    "superVisible": "Super 可见",
+    "adminVisible": "Admin 可见",
+    "userVisible": "User 可见"
+  },
+  "nested": {
+    "title": "嵌套菜单",
+    "menu1": "菜单 1",
+    "menu2": "菜单 2",
+    "menu2_1": "菜单 2-1",
+    "menu3": "菜单 3",
+    "menu3_1": "菜单 3-1",
+    "menu3_2": "菜单 3-2",
+    "menu3_2_1": "菜单 3-2-1"
+  },
+  "outside": {
+    "title": "外部页面",
+    "embedded": "内嵌",
+    "externalLink": "外链"
+  },
+  "badge": {
+    "title": "菜单徽标",
+    "dot": "点徽标",
+    "text": "文本徽标",
+    "color": "徽标颜色"
+  },
+  "activeIcon": {
+    "title": "菜单激活图标",
+    "children": "子级激活图标"
+  },
+  "fallback": {
+    "title": "缺省页"
+  },
+  "features": {
+    "title": "功能",
+    "hideChildrenInMenu": "隐藏子菜单",
+    "loginExpired": "登录过期",
+    "icons": "图标",
+    "watermark": "水印",
+    "tabs": "标签页",
+    "tabDetail": "标签详情页",
+    "fullScreen": "全屏",
+    "clipboard": "剪贴板",
+    "menuWithQuery": "带参菜单",
+    "openInNewWindow": "新窗口打开",
+    "fileDownload": "文件下载"
+  },
+  "breadcrumb": {
+    "navigation": "面包屑导航",
+    "lateral": "平级模式",
+    "level": "层级模式",
+    "levelDetail": "层级模式详情",
+    "lateralDetail": "平级模式详情"
+  },
+  "vben": {
+    "title": "项目",
+    "about": "关于",
+    "document": "文档",
+    "antdv": "Ant Design Vue 版本",
+    "naive-ui": "Naive UI 版本",
+    "element-plus": "Element Plus 版本"
+  }
+}

+ 70 - 0
apps/baicai-cms/src/locales/langs/zh-CN/examples.json

@@ -0,0 +1,70 @@
+{
+  "title": "示例",
+  "modal": {
+    "title": "弹窗"
+  },
+  "drawer": {
+    "title": "抽屉"
+  },
+  "ellipsis": {
+    "title": "文本省略"
+  },
+  "resize": {
+    "title": "拖动调整"
+  },
+  "form": {
+    "title": "表单",
+    "basic": "基础表单",
+    "layout": "自定义布局",
+    "query": "查询表单",
+    "rules": "表单校验",
+    "dynamic": "动态表单",
+    "custom": "自定义组件",
+    "api": "Api",
+    "merge": "合并表单"
+  },
+  "vxeTable": {
+    "title": "Vxe 表格",
+    "basic": "基础表格",
+    "remote": "远程加载",
+    "tree": "树形表格",
+    "fixed": "固定表头/列",
+    "virtual": "虚拟滚动",
+    "editCell": "单元格编辑",
+    "editRow": "行编辑",
+    "custom-cell": "自定义单元格",
+    "form": "搜索表单"
+  },
+  "captcha": {
+    "title": "验证码",
+    "pointSelection": "点选验证",
+    "sliderCaptcha": "滑块验证",
+    "sliderRotateCaptcha": "旋转验证",
+    "captchaCardTitle": "请完成安全验证",
+    "pageDescription": "通过点击图片中的特定位置来验证用户身份。",
+    "pageTitle": "验证码组件示例",
+    "basic": "基本使用",
+    "titlePlaceholder": "验证码标题文案",
+    "captchaImageUrlPlaceholder": "验证码图片(支持img标签src属性值)",
+    "hintImage": "提示图片",
+    "hintText": "提示文本",
+    "hintImagePlaceholder": "提示图片(支持img标签src属性值)",
+    "hintTextPlaceholder": "提示文本",
+    "showConfirm": "展示确认",
+    "hideConfirm": "隐藏确认",
+    "widthPlaceholder": "验证码图片宽度 默认300px",
+    "heightPlaceholder": "验证码图片高度 默认220px",
+    "paddingXPlaceholder": "水平内边距 默认12px",
+    "paddingYPlaceholder": "垂直内边距 默认16px",
+    "index": "索引:",
+    "timestamp": "时间戳:",
+    "x": "x:",
+    "y": "y:"
+  },
+  "layout": {
+    "col-page": "双列布局"
+  },
+  "button-group": {
+    "title": "按钮组"
+  }
+}

+ 16 - 0
apps/baicai-cms/src/locales/langs/zh-CN/page.json

@@ -0,0 +1,16 @@
+{
+  "auth": {
+    "login": "登录",
+    "register": "注册",
+    "codeLogin": "验证码登录",
+    "qrcodeLogin": "二维码登录",
+    "forgetPassword": "忘记密码",
+    "sendingCode": "正在发送验证码",
+    "codeSentTo": "验证码已发送至{0}"
+  },
+  "dashboard": {
+    "title": "概览",
+    "analytics": "分析页",
+    "workspace": "工作台"
+  }
+}

+ 31 - 0
apps/baicai-cms/src/main.ts

@@ -0,0 +1,31 @@
+import { initPreferences } from '@vben/preferences';
+import { unmountGlobalLoading } from '@vben/utils';
+
+import { overridesPreferences } from './preferences';
+
+/**
+ * 应用初始化完成之后再进行页面加载渲染
+ */
+async function initApplication() {
+  // name用于指定项目唯一标识
+  // 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
+  const env = import.meta.env.PROD ? 'prod' : 'dev';
+  const appVersion = import.meta.env.VITE_APP_VERSION;
+  const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
+
+  // app偏好设置初始化
+  await initPreferences({
+    namespace,
+    overrides: overridesPreferences,
+  });
+
+  // 启动应用并挂载
+  // vue应用主要逻辑及视图
+  const { bootstrap } = await import('./bootstrap');
+  await bootstrap(namespace);
+
+  // 移除并销毁loading
+  unmountGlobalLoading();
+}
+
+initApplication();

+ 18 - 0
apps/baicai-cms/src/preferences.ts

@@ -0,0 +1,18 @@
+import { defineOverridesPreferences } from '@vben/preferences';
+
+/**
+ * @description 项目配置文件
+ * 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
+ * !!! 更改配置后请清空缓存,否则可能不生效
+ */
+export const overridesPreferences = defineOverridesPreferences({
+  // overrides
+  app: {
+    name: import.meta.env.VITE_APP_TITLE,
+    accessMode: 'backend',
+    defaultHomePath: '/index',
+  },
+  theme: {
+    mode: 'light',
+  },
+});

+ 44 - 0
apps/baicai-cms/src/router/access.ts

@@ -0,0 +1,44 @@
+import type {
+  ComponentRecordType,
+  GenerateMenuAndRoutesOptions,
+} from '@vben/types';
+
+import { generateAccessible } from '@vben/access';
+import { preferences } from '@vben/preferences';
+
+import { message } from 'ant-design-vue';
+
+import { getAllMenusApi } from '#/api';
+import { BasicLayout, EmptyLayout, IFrameView, PageLayout } from '#/layouts';
+import { $t } from '#/locales';
+
+const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
+
+async function generateAccess(options: GenerateMenuAndRoutesOptions) {
+  const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
+
+  const layoutMap: ComponentRecordType = {
+    BasicLayout,
+    IFrameView,
+    EmptyLayout,
+    PageLayout,
+  };
+
+  return await generateAccessible(preferences.app.accessMode, {
+    ...options,
+    fetchMenuListAsync: async () => {
+      message.loading({
+        content: `${$t('common.loadingMenu')}...`,
+        duration: 1.5,
+      });
+      return await getAllMenusApi();
+    },
+    // 可以指定没有权限跳转403页面
+    forbiddenComponent,
+    // 如果 route.meta.menuVisibleWithForbidden = true
+    layoutMap,
+    pageMap,
+  });
+}
+
+export { generateAccess };

+ 131 - 0
apps/baicai-cms/src/router/guard.ts

@@ -0,0 +1,131 @@
+import type { Router } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences } from '@vben/preferences';
+import { useAccessStore, useUserStore } from '@vben/stores';
+import { startProgress, stopProgress } from '@vben/utils';
+
+import { accessRoutes, coreRouteNames } from '#/router/routes';
+import { useAuthStore, useWebStore } from '#/store';
+
+import { generateAccess } from './access';
+
+/**
+ * 通用守卫配置
+ * @param router
+ */
+function setupCommonGuard(router: Router) {
+  // 记录已经加载的页面
+  const loadedPaths = new Set<string>();
+  router.beforeEach((to) => {
+    to.meta.loaded = loadedPaths.has(to.path);
+
+    // 页面加载进度条
+    if (!to.meta.loaded && preferences.transition.progress) {
+      startProgress();
+    }
+    return true;
+  });
+
+  router.afterEach((to) => {
+    // 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
+    loadedPaths.add(to.path);
+
+    // 关闭页面加载进度条
+    if (preferences.transition.progress) {
+      stopProgress();
+    }
+  });
+}
+
+/**
+ * 权限访问守卫配置
+ * @param router
+ */
+function setupAccessGuard(router: Router) {
+  router.beforeEach(async (to, from) => {
+    const accessStore = useAccessStore();
+    const userStore = useUserStore();
+    const authStore = useAuthStore();
+    const webStore = useWebStore();
+
+    // 基本路由,这些路由不需要进入权限拦截
+    if (coreRouteNames.includes(to.name as string)) {
+      if (to.path === LOGIN_PATH && accessStore.accessToken) {
+        return decodeURIComponent(
+          (to.query?.redirect as string) ||
+            userStore.userInfo?.homePath ||
+            preferences.app.defaultHomePath,
+        );
+      }
+      return true;
+    }
+
+    // accessToken 检查
+    // if (!accessStore.accessToken) {
+    //   // 明确声明忽略权限访问权限,则可以访问
+    //   if (to.meta.ignoreAccess) {
+    //     return true;
+    //   }
+
+    //   // 没有访问权限,跳转登录页面
+    //   if (to.fullPath !== LOGIN_PATH) {
+    //     return {
+    //       path: LOGIN_PATH,
+    //       // 如不需要,直接删除 query
+    //       query:
+    //         to.fullPath === preferences.app.defaultHomePath
+    //           ? {}
+    //           : { redirect: encodeURIComponent(to.fullPath) },
+    //       // 携带当前跳转的页面,登录后重新跳转该页面
+    //       replace: true,
+    //     };
+    //   }
+    //   return to;
+    // }
+
+    // 是否已经生成过动态路由
+    if (accessStore.isAccessChecked) {
+      return true;
+    }
+
+    // 生成路由表
+    // 当前登录用户拥有的角色标识列表
+    webStore.config || (await authStore.loadConfig());
+
+    // 生成菜单和路由
+    const { accessibleMenus, accessibleRoutes } = await generateAccess({
+      roles: [],
+      router,
+      // 则会在菜单中显示,但是访问会被重定向到403
+      routes: accessRoutes,
+    });
+
+    // 保存菜单信息和路由信息
+    accessStore.setAccessMenus(accessibleMenus);
+    accessStore.setAccessRoutes(accessibleRoutes);
+    accessStore.setIsAccessChecked(true);
+    const redirectPath = (from.query.redirect ??
+      (to.path === preferences.app.defaultHomePath
+        ? preferences.app.defaultHomePath
+        : to.fullPath)) as string;
+
+    return {
+      ...router.resolve(decodeURIComponent(redirectPath)),
+      replace: true,
+    };
+  });
+}
+
+/**
+ * 项目守卫配置
+ * @param router
+ */
+function createRouterGuard(router: Router) {
+  /** 通用 */
+  setupCommonGuard(router);
+  /** 权限访问 */
+  setupAccessGuard(router);
+}
+
+export { createRouterGuard };

+ 36 - 0
apps/baicai-cms/src/router/index.ts

@@ -0,0 +1,36 @@
+import {
+  createRouter,
+  createWebHashHistory,
+  createWebHistory,
+} from 'vue-router';
+
+import { resetStaticRoutes } from '@vben/utils';
+
+import { createRouterGuard } from './guard';
+import { routes } from './routes';
+
+/**
+ *  @zh_CN 创建vue-router实例
+ */
+const router = createRouter({
+  history:
+    import.meta.env.VITE_ROUTER_HISTORY === 'hash'
+      ? createWebHashHistory(import.meta.env.VITE_BASE)
+      : createWebHistory(import.meta.env.VITE_BASE),
+  // 应该添加到路由的初始路由列表。
+  routes,
+  scrollBehavior: (to, _from, savedPosition) => {
+    if (savedPosition) {
+      return savedPosition;
+    }
+    return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
+  },
+  // 是否应该禁止尾部斜杠。
+  // strict: true,
+});
+
+const resetRoutes = () => resetStaticRoutes(router, routes);
+// 创建路由守卫
+createRouterGuard(router);
+
+export { resetRoutes, router };

+ 152 - 0
apps/baicai-cms/src/router/routes/core.ts

@@ -0,0 +1,152 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences } from '@vben/preferences';
+
+import { EmptyLayout, PageLayout } from '#/layouts';
+import { $t } from '#/locales';
+
+const AuthPageLayout = () => import('#/layouts/auth.vue');
+/** 全局404页面 */
+const fallbackNotFoundRoute: RouteRecordRaw = {
+  component: () => import('#/views/_core/fallback/not-found.vue'),
+  meta: {
+    hideInBreadcrumb: true,
+    hideInMenu: true,
+    hideInTab: true,
+    title: '404',
+  },
+  name: 'FallbackNotFound',
+  path: '/:path(.*)*',
+};
+/** 基本路由,这些路由是必须存在的 */
+const coreRoutes: RouteRecordRaw[] = [
+  /**
+   * 根路由
+   * 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
+   * 此路由必须存在,且不应修改
+   */
+  {
+    component: PageLayout,
+    meta: {
+      hideInBreadcrumb: true,
+      title: 'Root',
+    },
+    name: 'Root',
+    path: '/',
+    redirect: preferences.app.defaultHomePath,
+    children: [],
+  },
+  {
+    component: EmptyLayout,
+    meta: {
+      hideInTab: true,
+      title: 'Status',
+    },
+    name: 'Status',
+    path: '/status',
+    children: [
+      {
+        name: 'Status403',
+        path: '403',
+        component: () => import('#/views/_core/fallback/forbidden.vue'),
+        meta: {
+          icon: 'mdi:do-not-disturb-alt',
+          title: '403',
+        },
+      },
+      {
+        name: 'Status404',
+        path: '404',
+        component: () => import('#/views/_core/fallback/not-found.vue'),
+        meta: {
+          icon: 'mdi:table-off',
+          title: '404',
+        },
+      },
+      {
+        name: 'Status500',
+        path: '500',
+        component: () => import('#/views/_core/fallback/internal-error.vue'),
+        meta: {
+          icon: 'mdi:server-network-off',
+          title: '500',
+        },
+      },
+      {
+        name: 'Offline',
+        path: 'offline',
+        component: () => import('#/views/_core/fallback/offline.vue'),
+        meta: {
+          icon: 'mdi:offline',
+          title: $t('ui.fallback.offline'),
+        },
+      },
+      {
+        name: 'ComingSoon',
+        path: 'coming-soon',
+        component: () => import('#/views/_core/fallback/coming-soon.vue'),
+        meta: {
+          icon: 'mdi:offline',
+          title: $t('ui.fallback.comingSoon'),
+        },
+      },
+    ],
+  },
+  {
+    component: AuthPageLayout,
+    meta: {
+      hideInTab: true,
+      title: 'Authentication',
+    },
+    name: 'Authentication',
+    path: '/auth',
+    redirect: LOGIN_PATH,
+    children: [
+      {
+        name: 'Login',
+        path: 'login',
+        component: () => import('#/views/_core/authentication/login.vue'),
+        meta: {
+          title: $t('page.auth.login'),
+        },
+      },
+      {
+        name: 'CodeLogin',
+        path: 'code-login',
+        component: () => import('#/views/_core/authentication/code-login.vue'),
+        meta: {
+          title: $t('page.auth.codeLogin'),
+        },
+      },
+      {
+        name: 'QrCodeLogin',
+        path: 'qrcode-login',
+        component: () =>
+          import('#/views/_core/authentication/qrcode-login.vue'),
+        meta: {
+          title: $t('page.auth.qrcodeLogin'),
+        },
+      },
+      {
+        name: 'ForgetPassword',
+        path: 'forget-password',
+        component: () =>
+          import('#/views/_core/authentication/forget-password.vue'),
+        meta: {
+          title: $t('page.auth.forgetPassword'),
+        },
+      },
+      {
+        name: 'Register',
+        path: 'register',
+        component: () => import('#/views/_core/authentication/register.vue'),
+        meta: {
+          title: $t('page.auth.register'),
+        },
+      },
+    ],
+  },
+];
+
+export { coreRoutes, fallbackNotFoundRoute };

+ 49 - 0
apps/baicai-cms/src/router/routes/index.ts

@@ -0,0 +1,49 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
+
+import { coreRoutes, fallbackNotFoundRoute } from './core';
+
+const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
+  eager: true,
+});
+
+// 有需要可以自行打开注释,并创建文件夹
+const externalRouteFiles = import.meta.glob('./external/**/*.ts', {
+  eager: true,
+});
+// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
+
+/** 动态路由 */
+const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
+
+/** 外部路由列表,访问这些页面可以不需要Layout,可能用于内嵌在别的系统(不会显示在菜单中) */
+const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
+// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
+const staticRoutes: RouteRecordRaw[] = [];
+// const externalRoutes: RouteRecordRaw[] = [];
+
+/** 路由列表,由基本路由、外部路由和404兜底路由组成
+ *  无需走权限验证(会一直显示在菜单中) */
+const routes: RouteRecordRaw[] = [
+  ...coreRoutes,
+  ...externalRoutes,
+  fallbackNotFoundRoute,
+];
+
+/** 基本路由列表,这些路由不需要进入权限拦截 */
+const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
+
+/** 有权限校验的路由列表,包含动态路由和静态路由 */
+const accessRoutes = [...dynamicRoutes, ...staticRoutes];
+
+const componentKeys: string[] = Object.keys(
+  import.meta.glob('../../views/**/*.vue'),
+)
+  .filter((item) => !item.includes('/modules/'))
+  .map((v) => {
+    const path = v.replace('../../views/', '/');
+    return path.endsWith('.vue') ? path.slice(0, -4) : path;
+  });
+
+export { accessRoutes, componentKeys, coreRouteNames, routes };

+ 31 - 0
apps/baicai-cms/src/router/routes/modules/dashboard.ts

@@ -0,0 +1,31 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { PageLayout } from '#/layouts';
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+  {
+    component: PageLayout,
+    meta: {
+      icon: 'lucide:layout-dashboard',
+      order: -1,
+      title: $t('page.dashboard.title'),
+    },
+    name: 'Dashboard',
+    path: '/dashboard',
+    children: [
+      {
+        name: 'index',
+        path: 'index',
+        component: () => import('#/views/dashboard/home/index.vue'),
+        meta: {
+          affixTab: true,
+          icon: 'lucide:area-chart',
+          title: $t('工作台'),
+        },
+      },
+    ],
+  },
+];
+
+export default routes;

+ 94 - 0
apps/baicai-cms/src/router/routes/modules/vben.ts

@@ -0,0 +1,94 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import {
+  VBEN_ANT_PREVIEW_URL,
+  VBEN_DOC_URL,
+  VBEN_ELE_PREVIEW_URL,
+  VBEN_GITHUB_URL,
+  VBEN_LOGO_URL,
+  VBEN_NAIVE_PREVIEW_URL,
+} from '@vben/constants';
+import { SvgAntdvLogoIcon } from '@vben/icons';
+
+import { BasicLayout, IFrameView } from '#/layouts';
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+  {
+    component: BasicLayout,
+    meta: {
+      badgeType: 'dot',
+      icon: VBEN_LOGO_URL,
+      order: 9999,
+      title: $t('page.vben.title'),
+    },
+    name: 'VbenProject',
+    path: '/vben-admin',
+    children: [
+      {
+        name: 'VbenAbout',
+        path: '/vben-admin/about',
+        component: () => import('#/views/_core/about/index.vue'),
+        meta: {
+          icon: 'lucide:copyright',
+          title: $t('page.vben.about'),
+        },
+      },
+      {
+        name: 'VbenDocument',
+        path: '/vben-admin/document',
+        component: IFrameView,
+        meta: {
+          icon: 'lucide:book-open-text',
+          link: VBEN_DOC_URL,
+          title: $t('page.vben.document'),
+        },
+      },
+      {
+        name: 'VbenGithub',
+        path: '/vben-admin/github',
+        component: IFrameView,
+        meta: {
+          icon: 'mdi:github',
+          link: VBEN_GITHUB_URL,
+          title: 'Github',
+        },
+      },
+      {
+        name: 'VbenAntdv',
+        path: '/vben-admin/antdv',
+        component: IFrameView,
+        meta: {
+          badgeType: 'dot',
+          icon: SvgAntdvLogoIcon,
+          link: VBEN_ANT_PREVIEW_URL,
+          title: $t('page.vben.antdv'),
+        },
+      },
+      {
+        name: 'VbenNaive',
+        path: '/vben-admin/naive',
+        component: IFrameView,
+        meta: {
+          badgeType: 'dot',
+          icon: 'logos:naiveui',
+          link: VBEN_NAIVE_PREVIEW_URL,
+          title: $t('page.vben.naive-ui'),
+        },
+      },
+      {
+        name: 'VbenElementPlus',
+        path: '/vben-admin/ele',
+        component: IFrameView,
+        meta: {
+          badgeType: 'dot',
+          icon: 'logos:element',
+          link: VBEN_ELE_PREVIEW_URL,
+          title: $t('page.vben.element-plus'),
+        },
+      },
+    ],
+  },
+];
+
+export default routes;

+ 34 - 0
apps/baicai-cms/src/store/app.ts

@@ -0,0 +1,34 @@
+import type { SiteApi } from '#/api';
+
+import { acceptHMRUpdate, defineStore } from 'pinia';
+
+interface CmsWebState {
+  config: null | SiteApi.SiteRecordItem;
+  hasConfig: boolean;
+}
+
+/**
+ * @zh_CN 访问权限相关
+ */
+export const useWebStore = defineStore('cms-web', {
+  actions: {
+    setConfig(config: SiteApi.SiteRecordItem) {
+      this.hasConfig = true;
+      this.config = config;
+    },
+  },
+  persist: {
+    // 持久化
+    pick: ['config'],
+  },
+  state: (): CmsWebState => ({
+    config: null,
+    hasConfig: false,
+  }),
+});
+
+// 解决热更新问题
+const hot = import.meta.hot;
+if (hot) {
+  hot.accept(acceptHMRUpdate(useWebStore, hot));
+}

+ 151 - 0
apps/baicai-cms/src/store/auth.ts

@@ -0,0 +1,151 @@
+import type { Recordable, UserInfo } from '@vben/types';
+
+import type { AuthApi } from '#/api';
+
+import { ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences, usePreferences } from '@vben/preferences';
+import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
+
+import { notification } from 'ant-design-vue';
+import { defineStore } from 'pinia';
+
+import {
+  getAccessCodesApi,
+  getUserInfoApi,
+  loginApi,
+  logoutApi,
+  SiteApi,
+} from '#/api';
+import { $t } from '#/locales';
+
+import { useWebStore } from './app';
+
+export const useAuthStore = defineStore('auth', () => {
+  const accessStore = useAccessStore();
+  const userStore = useUserStore();
+  const router = useRouter();
+  const webStore = useWebStore();
+  const { locale } = usePreferences();
+  const loginLoading = ref(false);
+
+  async function loadConfig() {
+    let siteConfig: null | SiteApi.SiteRecordItem = null;
+    try {
+      siteConfig = await SiteApi.getConfig({ lang: locale.value });
+      if (siteConfig) {
+        if (siteConfig.status === 2) {
+          await router.push('/status/coming-soon');
+        } else {
+          webStore.setConfig(siteConfig);
+        }
+      } else {
+        await router.push('/status/404');
+      }
+    } catch {
+      await router.push('/status/500');
+    }
+    return siteConfig;
+  }
+
+  /**
+   * 异步处理登录操作
+   * Asynchronously handle the login process
+   * @param params 登录表单数据
+   * @param onSuccess 成功之后的回调函数
+   */
+  async function authLogin(
+    params: Recordable<any>,
+    onSuccess?: () => Promise<void> | void,
+  ) {
+    // 异步处理用户登录操作并获取 accessToken
+    let userInfo: null | UserInfo = null;
+    try {
+      loginLoading.value = true;
+      const { accessToken } = await loginApi(params as AuthApi.LoginParams);
+
+      // 如果成功获取到 accessToken
+      if (accessToken) {
+        accessStore.setAccessToken(accessToken);
+
+        // 获取用户信息并存储到 accessStore 中
+        const [fetchUserInfoResult, accessCodes] = await Promise.all([
+          fetchUserInfo(),
+          getAccessCodesApi(),
+        ]);
+
+        userInfo = fetchUserInfoResult;
+
+        userStore.setUserInfo(userInfo);
+        accessStore.setAccessCodes(accessCodes);
+
+        if (accessStore.loginExpired) {
+          accessStore.setLoginExpired(false);
+        } else {
+          onSuccess
+            ? await onSuccess?.()
+            : await router.push(
+                userInfo.homePath || preferences.app.defaultHomePath,
+              );
+        }
+
+        if (userInfo?.realName) {
+          notification.success({
+            description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
+            duration: 3,
+            message: $t('authentication.loginSuccess'),
+          });
+        }
+      }
+    } finally {
+      loginLoading.value = false;
+    }
+
+    return {
+      userInfo,
+    };
+  }
+
+  async function logout(redirect: boolean = true) {
+    try {
+      await logoutApi();
+    } catch {
+      // 不做任何处理
+    }
+
+    resetAllStores();
+    accessStore.setLoginExpired(false);
+
+    // 回登录页带上当前路由地址
+    await router.replace({
+      path: LOGIN_PATH,
+      query: redirect
+        ? {
+            redirect: encodeURIComponent(router.currentRoute.value.fullPath),
+          }
+        : {},
+    });
+  }
+
+  async function fetchUserInfo() {
+    let userInfo: null | UserInfo = null;
+    userInfo = await getUserInfoApi();
+    userStore.setUserInfo(userInfo);
+    return userInfo;
+  }
+
+  function $reset() {
+    loginLoading.value = false;
+  }
+
+  return {
+    $reset,
+    authLogin,
+    fetchUserInfo,
+    loginLoading,
+    logout,
+    loadConfig,
+  };
+});

+ 2 - 0
apps/baicai-cms/src/store/index.ts

@@ -0,0 +1,2 @@
+export * from './app';
+export * from './auth';

+ 7 - 0
apps/baicai-cms/src/utils/cryptogram.ts

@@ -0,0 +1,7 @@
+import { sm2 } from 'sm-crypto-v2';
+
+function encrypt(password: string) {
+  return sm2.doEncrypt(password, import.meta.env.VITE_GLOB_PUBLIC_KEY, 1);
+}
+
+export { encrypt };

+ 3 - 0
apps/baicai-cms/src/utils/index.ts

@@ -0,0 +1,3 @@
+export * from './cryptogram';
+export * from './tree';
+export * from './utils';

+ 45 - 0
apps/baicai-cms/src/utils/tree.ts

@@ -0,0 +1,45 @@
+interface TreeHelperConfig {
+  id: string;
+  children: string;
+  pid: string;
+}
+const DEFAULT_CONFIG: TreeHelperConfig = {
+  id: 'id',
+  children: 'children',
+  pid: 'pid',
+};
+
+const getConfig = (config: Partial<TreeHelperConfig>) =>
+  Object.assign({}, DEFAULT_CONFIG, config);
+
+export function filter<T = any>(
+  tree: T[],
+  func: (n: T) => boolean,
+  config: Partial<TreeHelperConfig> = {},
+): T[] {
+  config = getConfig(config);
+  const children = config.children as string;
+  function listFilter(list: T[]) {
+    return list
+      .map((node: any) => ({ ...node }))
+      .filter((node) => {
+        node[children] = node[children] && listFilter(node[children]);
+        return func(node) || (node[children] && node[children].length > 0);
+      });
+  }
+  return listFilter(tree);
+}
+
+export function treeToList<T = any>(
+  tree: any,
+  config: Partial<TreeHelperConfig> = {},
+): T {
+  config = getConfig(config);
+  const { children = 'children' } = config;
+  const result: any = [...tree];
+  for (let i = 0; i < result.length; i++) {
+    if (!result[i][children]) continue;
+    result.splice(i + 1, 0, ...result[i][children]);
+  }
+  return result;
+}

+ 139 - 0
apps/baicai-cms/src/utils/utils.ts

@@ -0,0 +1,139 @@
+/**
+ * -转大驼峰
+ * @param str
+ */
+export const toPascalCase = (str: any) => {
+  // 将连字符或下划线替换为空格,以便后续处理
+  const words = str.replaceAll(/[-_]/g, ' ').split(' ');
+
+  // 将每个单词的首字母大写,并将其余部分保持原样
+  const pascalCaseWords = words.map((word: any) => {
+    if (word) {
+      return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+    }
+    return word;
+  });
+
+  // 将处理后的单词拼接成一个新的字符串
+  return pascalCaseWords.join('');
+};
+
+/**
+ * 驼峰转下划线
+ * @param str
+ */
+export function toLine(str: string) {
+  return str.replaceAll(/([A-Z])/g, '_$1').toLowerCase();
+}
+export const omit = (obj: any, keysToOmit: string[]) => {
+  // 如果 obj 不是对象或者 keysToOmit 不是数组,则直接返回 obj
+  if (typeof obj !== 'object' || !Array.isArray(keysToOmit)) {
+    return obj;
+  }
+
+  // 创建一个新的对象,避免修改原始对象
+  const result = {} as any;
+
+  // 遍历 obj 的所有键值对
+  for (const key in obj) {
+    // 如果当前键不在要忽略的键列表中,则复制到新对象
+    if (!keysToOmit.includes(key)) {
+      result[key] = obj[key];
+    }
+  }
+
+  return result;
+};
+export const get = (object: any, path: string) => {
+  // 如果 object 不是对象或者 path 不是字符串,则直接返回 defaultValue
+  if (
+    typeof object !== 'object' ||
+    object === null ||
+    typeof path !== 'string'
+  ) {
+    return object;
+  }
+
+  // 将路径字符串转换为数组
+  const pathArray = path.split('.').filter(Boolean); // 过滤掉空字符串
+  let current = object;
+
+  // 遍历路径数组
+  for (const element of pathArray) {
+    // 如果当前层级不是对象或没有对应的键,则返回 defaultValue
+    if (
+      typeof current !== 'object' ||
+      current === null ||
+      !(element in current)
+    ) {
+      return object;
+    }
+    // 更新 current 到下一层级
+    current = current[element];
+  }
+
+  // 返回最终找到的值
+  return current;
+};
+
+export function getFileNameWithoutExtension(path: string) {
+  // 使用正则表达式匹配最后一个 / 后面的所有字符,但不包括最后一个点(.)之后的内容
+  const match = path.match(/[^/]+(?=\.[^./]+$|$)/);
+  return match ? match[0].replace(/\.[^/.]+$/, '') : null;
+}
+
+const hexList: string[] = [];
+for (let i = 0; i <= 15; i++) {
+  hexList[i] = i.toString(16);
+}
+export function buildUUID(): string {
+  let uuid = '';
+  for (let i = 1; i <= 36; i++) {
+    switch (i) {
+      case 9:
+      case 14:
+      case 19:
+      case 24: {
+        uuid += '-';
+
+        break;
+      }
+      case 15: {
+        uuid += 4;
+
+        break;
+      }
+      case 20: {
+        uuid += hexList[(Math.random() * 4) | 8];
+
+        break;
+      }
+      default: {
+        uuid += hexList[Math.trunc(Math.random() * 16)];
+      }
+    }
+  }
+  return uuid.replaceAll('-', '');
+}
+export function deepMerge(
+  target: Record<string, any>,
+  source: Record<string, any>,
+): Record<string, any> {
+  for (const key in source) {
+    if (Reflect.has(source, key)) {
+      if (
+        typeof source[key] === 'object' &&
+        source[key] !== null &&
+        !Array.isArray(source[key])
+      ) {
+        if (typeof target[key] !== 'object' || target[key] === null) {
+          target[key] = {};
+        }
+        deepMerge(target[key], source[key]);
+      } else {
+        target[key] = source[key];
+      }
+    }
+  }
+  return target;
+}

+ 3 - 0
apps/baicai-cms/src/views/_core/README.md

@@ -0,0 +1,3 @@
+# \_core
+
+此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。

+ 9 - 0
apps/baicai-cms/src/views/_core/about/index.vue

@@ -0,0 +1,9 @@
+<script lang="ts" setup>
+import { About } from '@vben/common-ui';
+
+defineOptions({ name: 'About' });
+</script>
+
+<template>
+  <About />
+</template>

+ 109 - 0
apps/baicai-cms/src/views/_core/authentication/code-login.vue

@@ -0,0 +1,109 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
+
+import { computed, ref, useTemplateRef } from 'vue';
+
+import { AuthenticationCodeLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import { message } from 'ant-design-vue';
+
+defineOptions({ name: 'CodeLogin' });
+
+const loading = ref(false);
+const CODE_LENGTH = 6;
+const loginRef =
+  useTemplateRef<InstanceType<typeof AuthenticationCodeLogin>>('loginRef');
+function sendCodeApi(phoneNumber: string) {
+  message.loading({
+    content: $t('page.auth.sendingCode'),
+    duration: 0,
+    key: 'sending-code',
+  });
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      message.success({
+        content: $t('page.auth.codeSentTo', [phoneNumber]),
+        duration: 3,
+        key: 'sending-code',
+      });
+      resolve({ code: '123456', phoneNumber });
+    }, 3000);
+  });
+}
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.mobile'),
+      },
+      fieldName: 'phoneNumber',
+      label: $t('authentication.mobile'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.mobileTip') })
+        .refine((v) => /^\d{11}$/.test(v), {
+          message: $t('authentication.mobileErrortip'),
+        }),
+    },
+    {
+      component: 'VbenPinInput',
+      componentProps: {
+        codeLength: CODE_LENGTH,
+        createText: (countdown: number) => {
+          const text =
+            countdown > 0
+              ? $t('authentication.sendText', [countdown])
+              : $t('authentication.sendCode');
+          return text;
+        },
+        handleSendCode: async () => {
+          // 模拟发送验证码
+          // Simulate sending verification code
+          loading.value = true;
+          const formApi = loginRef.value?.getFormApi();
+          if (!formApi) {
+            loading.value = false;
+            throw new Error('formApi is not ready');
+          }
+          await formApi.validateField('phoneNumber');
+          const isPhoneReady = await formApi.isFieldValid('phoneNumber');
+          if (!isPhoneReady) {
+            loading.value = false;
+            throw new Error('Phone number is not Ready');
+          }
+          const { phoneNumber } = await formApi.getValues();
+          await sendCodeApi(phoneNumber);
+          loading.value = false;
+        },
+        placeholder: $t('authentication.code'),
+      },
+      fieldName: 'code',
+      label: $t('authentication.code'),
+      rules: z.string().length(CODE_LENGTH, {
+        message: $t('authentication.codeTip', [CODE_LENGTH]),
+      }),
+    },
+  ];
+});
+/**
+ * 异步处理登录操作
+ * Asynchronously handle the login process
+ * @param values 登录表单数据
+ */
+async function handleLogin(values: Recordable<any>) {
+  // eslint-disable-next-line no-console
+  console.log(values);
+}
+</script>
+
+<template>
+  <AuthenticationCodeLogin
+    ref="loginRef"
+    :form-schema="formSchema"
+    :loading="loading"
+    @submit="handleLogin"
+  />
+</template>

+ 42 - 0
apps/baicai-cms/src/views/_core/authentication/forget-password.vue

@@ -0,0 +1,42 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '@vben/common-ui';
+
+import { computed, ref } from 'vue';
+
+import { AuthenticationForgetPassword, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+defineOptions({ name: 'ForgetPassword' });
+
+const loading = ref(false);
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: 'example@example.com',
+      },
+      fieldName: 'email',
+      label: $t('authentication.email'),
+      rules: z
+        .string()
+        .min(1, { message: $t('authentication.emailTip') })
+        .email($t('authentication.emailValidErrorTip')),
+    },
+  ];
+});
+
+function handleSubmit(value: Record<string, any>) {
+  // eslint-disable-next-line no-console
+  console.log('reset email:', value);
+}
+</script>
+
+<template>
+  <AuthenticationForgetPassword
+    :form-schema="formSchema"
+    :loading="loading"
+    @submit="handleSubmit"
+  />
+</template>

+ 74 - 0
apps/baicai-cms/src/views/_core/authentication/login.vue

@@ -0,0 +1,74 @@
+<script lang="ts" setup>
+import type { SliderCaptcha, VbenFormSchema } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
+
+import { computed, useTemplateRef } from 'vue';
+
+import { AuthenticationLogin, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import { useAuthStore } from '#/store';
+
+defineOptions({ name: 'Login' });
+
+const authStore = useAuthStore();
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    // {
+    //   component: markRaw(SliderCaptcha),
+    //   fieldName: 'captcha',
+    //   rules: z.boolean().refine((value) => value, {
+    //     message: $t('authentication.verifyRequiredTip'),
+    //   }),
+    // },
+  ];
+});
+const loginRef =
+  useTemplateRef<InstanceType<typeof AuthenticationLogin>>('loginRef');
+
+async function onSubmit(params: Recordable<any>) {
+  authStore.authLogin(params).catch(() => {
+    // 登陆失败,刷新验证码的演示
+
+    // 使用表单API获取验证码组件实例,并调用其resume方法来重置验证码
+    loginRef.value
+      ?.getFormApi()
+      ?.getFieldComponentRef<InstanceType<typeof SliderCaptcha>>('captcha')
+      ?.resume();
+  });
+}
+</script>
+
+<template>
+  <AuthenticationLogin
+    ref="loginRef"
+    :form-schema="formSchema"
+    :loading="authStore.loginLoading"
+    :show-code-login="false"
+    :show-forget-password="false"
+    :show-qrcode-login="false"
+    :show-register="false"
+    :show-remember-me="false"
+    :show-third-party-login="false"
+    @submit="onSubmit"
+  />
+</template>

+ 10 - 0
apps/baicai-cms/src/views/_core/authentication/qrcode-login.vue

@@ -0,0 +1,10 @@
+<script lang="ts" setup>
+import { AuthenticationQrCodeLogin } from '@vben/common-ui';
+import { LOGIN_PATH } from '@vben/constants';
+
+defineOptions({ name: 'QrCodeLogin' });
+</script>
+
+<template>
+  <AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
+</template>

+ 96 - 0
apps/baicai-cms/src/views/_core/authentication/register.vue

@@ -0,0 +1,96 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '@vben/common-ui';
+import type { Recordable } from '@vben/types';
+
+import { computed, h, ref } from 'vue';
+
+import { AuthenticationRegister, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+defineOptions({ name: 'Register' });
+
+const loading = ref(false);
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        passwordStrength: true,
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      renderComponentContent() {
+        return {
+          strengthText: () => $t('authentication.passwordStrength'),
+        };
+      },
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.confirmPassword'),
+      },
+      dependencies: {
+        rules(values) {
+          const { password } = values;
+          return z
+            .string({ required_error: $t('authentication.passwordTip') })
+            .min(1, { message: $t('authentication.passwordTip') })
+            .refine((value) => value === password, {
+              message: $t('authentication.confirmPasswordTip'),
+            });
+        },
+        triggerFields: ['password'],
+      },
+      fieldName: 'confirmPassword',
+      label: $t('authentication.confirmPassword'),
+    },
+    {
+      component: 'VbenCheckbox',
+      fieldName: 'agreePolicy',
+      renderComponentContent: () => ({
+        default: () =>
+          h('span', [
+            $t('authentication.agree'),
+            h(
+              'a',
+              {
+                class: 'vben-link ml-1 ',
+                href: '',
+              },
+              `${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
+            ),
+          ]),
+      }),
+      rules: z.boolean().refine((value) => !!value, {
+        message: $t('authentication.agreeTip'),
+      }),
+    },
+  ];
+});
+
+function handleSubmit(value: Recordable<any>) {
+  // eslint-disable-next-line no-console
+  console.log('register submit:', value);
+}
+</script>
+
+<template>
+  <AuthenticationRegister
+    :form-schema="formSchema"
+    :loading="loading"
+    @submit="handleSubmit"
+  />
+</template>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません