فهرست منبع

feat: 添加菜单

DESKTOP-USV654P\pc 1 سال پیش
والد
کامیت
733a8d6152
32فایلهای تغییر یافته به همراه1655 افزوده شده و 353 حذف شده
  1. 1 1
      apps/web-baicai/.env
  2. 139 0
      apps/web-baicai/src/adapter/component/index.ts
  3. 6 99
      apps/web-baicai/src/adapter/form.ts
  4. 7 4
      apps/web-baicai/src/api/core/auth.ts
  5. 6 0
      apps/web-baicai/src/api/model/index.ts
  6. 8 4
      apps/web-baicai/src/api/request.ts
  7. 3 0
      apps/web-baicai/src/api/system/enum.ts
  8. 1 0
      apps/web-baicai/src/api/system/index.ts
  9. 39 0
      apps/web-baicai/src/api/system/menu.ts
  10. 4 5
      apps/web-baicai/src/bootstrap.ts
  11. 27 0
      apps/web-baicai/src/components/form/component-map.ts
  12. 131 0
      apps/web-baicai/src/components/form/components/api-checkbox.vue
  13. 137 0
      apps/web-baicai/src/components/form/components/api-radio.vue
  14. 147 0
      apps/web-baicai/src/components/form/components/api-select.vue
  15. 13 0
      apps/web-baicai/src/components/form/types/index.d.ts
  16. 19 0
      apps/web-baicai/src/components/icon/icon.vue
  17. 1 0
      apps/web-baicai/src/components/icon/index.ts
  18. 2 0
      apps/web-baicai/src/components/table-action/index.ts
  19. 177 0
      apps/web-baicai/src/components/table-action/table-action.vue
  20. 25 0
      apps/web-baicai/src/components/table-action/types.d.ts
  21. 1 0
      apps/web-baicai/src/utils/index.ts
  22. 69 0
      apps/web-baicai/src/utils/utils.ts
  23. 11 9
      apps/web-baicai/src/views/_core/authentication/login.vue
  24. 8 21
      apps/web-baicai/src/views/system/design/query/components/stepBaseConfig.vue
  25. 8 1
      apps/web-baicai/src/views/system/design/query/data.config.ts
  26. 26 36
      apps/web-baicai/src/views/system/design/query/index.vue
  27. 78 0
      apps/web-baicai/src/views/system/menu/components/edit.vue
  28. 305 0
      apps/web-baicai/src/views/system/menu/data.config.ts
  29. 96 0
      apps/web-baicai/src/views/system/menu/index.vue
  30. 7 126
      apps/web-baicai/src/views/system/tenant/components/edit.vue
  31. 120 4
      apps/web-baicai/src/views/system/tenant/data.config.ts
  32. 33 43
      apps/web-baicai/src/views/system/tenant/index.vue

+ 1 - 1
apps/web-baicai/.env

@@ -2,7 +2,7 @@
 VITE_APP_TITLE=Baicai Admin
 
 # 应用命名空间,用于缓存、store等功能的前缀,确保隔离
-VITE_APP_NAMESPACE=baicai-web-play
+VITE_APP_NAMESPACE=baicai-web
 
 # 公钥
 VITE_GLOB_PUBLIC_KEY = 04A8C613723930314A9067B690148EB526650109E5F8390EE22AA97BEFEF8BD38E6AAA4762392BB5360BF1F7895D66A83F72EA3FD1D5350BC2394E285088E23E8F

+ 139 - 0
apps/web-baicai/src/adapter/component/index.ts

@@ -0,0 +1,139 @@
+/**
+ * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
+ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
+ */
+
+import type { BaseFormComponentType } from '@vben/common-ui';
+
+import type { CustomComponentType } from '#/components/form/types';
+
+import type { Component, SetupContext } from 'vue';
+import { h } from 'vue';
+
+import { globalShareState } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import {
+  AutoComplete,
+  Button,
+  Checkbox,
+  CheckboxGroup,
+  DatePicker,
+  Divider,
+  Input,
+  InputNumber,
+  InputPassword,
+  Mentions,
+  notification,
+  Radio,
+  RadioGroup,
+  RangePicker,
+  Rate,
+  Select,
+  Space,
+  Switch,
+  Textarea,
+  TimePicker,
+  TreeSelect,
+  Upload,
+} from 'ant-design-vue';
+
+import { componentMap } from '#/components/form/component-map';
+
+const withDefaultPlaceholder = <T extends Component>(
+  component: T,
+  type: 'input' | 'select',
+) => {
+  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
+    const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
+    return h(component, { ...props, ...attrs, placeholder }, slots);
+  };
+};
+
+// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
+export type ComponentType =
+  | 'AutoComplete'
+  | 'Checkbox'
+  | 'CheckboxGroup'
+  | 'DatePicker'
+  | 'DefaultButton'
+  | 'Divider'
+  | '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),
+
+    AutoComplete,
+    Checkbox,
+    CheckboxGroup,
+    DatePicker,
+    // 自定义默认按钮
+    DefaultButton: (props, { attrs, slots }) => {
+      return h(Button, { ...props, attrs, type: 'default' }, slots);
+    },
+    Divider,
+    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,
+  };
+
+  // 自动注册自定义组件
+  componentMap.keys().forEach((key) => {
+    components[key as ComponentType] = componentMap.get(
+      key as CustomComponentType,
+    ) as Component;
+  });
+
+  // 将组件注册到全局共享状态中
+  globalShareState.setComponents(components);
+
+  // 定义全局共享状态中的消息提示
+  globalShareState.defineMessage({
+    // 复制成功消息提示
+    copyPreferencesSuccess: (title, content) => {
+      notification.success({
+        description: content,
+        message: title,
+        placement: 'bottomRight',
+      });
+    },
+  });
+}
+
+export { initComponentAdapter };

+ 6 - 99
apps/web-baicai/src/adapter/form.ts

@@ -1,109 +1,17 @@
 import type {
-  BaseFormComponentType,
   VbenFormSchema as FormSchema,
   VbenFormProps,
 } from '@vben/common-ui';
 
-import type { Component, SetupContext } from 'vue';
-import { h } from 'vue';
+import type { ComponentType } from './component';
 
 import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
 import { $t } from '@vben/locales';
 
-import {
-  AutoComplete,
-  Button,
-  Checkbox,
-  CheckboxGroup,
-  DatePicker,
-  Divider,
-  Input,
-  InputNumber,
-  InputPassword,
-  Mentions,
-  Radio,
-  RadioGroup,
-  RangePicker,
-  Rate,
-  Select,
-  Space,
-  Switch,
-  Textarea,
-  TimePicker,
-  TreeSelect,
-  Upload,
-} from 'ant-design-vue';
-
-// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
-export type FormComponentType =
-  | 'AutoComplete'
-  | 'Checkbox'
-  | 'CheckboxGroup'
-  | 'DatePicker'
-  | 'Divider'
-  | 'Input'
-  | 'InputNumber'
-  | 'InputPassword'
-  | 'Mentions'
-  | 'Radio'
-  | 'RadioGroup'
-  | 'RangePicker'
-  | 'Rate'
-  | 'Select'
-  | 'Space'
-  | 'Switch'
-  | 'Textarea'
-  | 'TimePicker'
-  | 'TreeSelect'
-  | 'Upload'
-  | BaseFormComponentType;
-
-const withDefaultPlaceholder = <T extends Component>(
-  component: T,
-  type: 'input' | 'select',
-) => {
-  return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
-    const placeholder = props?.placeholder || $t(`placeholder.${type}`);
-    return h(component, { ...props, ...attrs, placeholder }, slots);
-  };
-};
-
-// 初始化表单组件,并注册到form组件内部
-setupVbenForm<FormComponentType>({
-  components: {
-    AutoComplete,
-    Checkbox,
-    CheckboxGroup,
-    DatePicker,
-    // 自定义默认的重置按钮
-    DefaultResetActionButton: (props, { attrs, slots }) => {
-      return h(Button, { ...props, attrs, type: 'default' }, slots);
-    },
-    // 自定义默认的提交按钮
-    DefaultSubmitActionButton: (props, { attrs, slots }) => {
-      return h(Button, { ...props, attrs, type: 'primary' }, slots);
-    },
-    Divider,
-    Input: withDefaultPlaceholder(Input, 'input'),
-    InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
-    InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
-    Mentions: withDefaultPlaceholder(Mentions, 'input'),
-    Radio,
-    RadioGroup,
-    RangePicker,
-    Rate,
-    Select: withDefaultPlaceholder(Select, 'select'),
-    Space,
-    Switch,
-    Textarea: withDefaultPlaceholder(Textarea, 'input'),
-    TimePicker,
-    TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
-    Upload,
-  },
+setupVbenForm<ComponentType>({
   config: {
     // ant design vue组件库默认都是 v-model:value
     baseModelPropName: 'value',
-
     // 一些组件是 v-model:checked 或者 v-model:fileList
     modelPropNameMap: {
       Checkbox: 'checked',
@@ -116,23 +24,22 @@ setupVbenForm<FormComponentType>({
     // 输入项目必填国际化适配
     required: (value, _params, ctx) => {
       if (value === undefined || value === null || value.length === 0) {
-        return $t('formRules.required', [ctx.label]);
+        return $t('ui.formRules.required', [ctx.label]);
       }
       return true;
     },
     // 选择项目必填国际化适配
     selectRequired: (value, _params, ctx) => {
       if (value === undefined || value === null) {
-        return $t('formRules.selectRequired', [ctx.label]);
+        return $t('ui.formRules.selectRequired', [ctx.label]);
       }
       return true;
     },
   },
 });
 
-const useVbenForm = useForm<FormComponentType>;
+const useVbenForm = useForm<ComponentType>;
 
 export { useVbenForm, z };
-
-export type VbenFormSchema = FormSchema<FormComponentType>;
+export type VbenFormSchema = FormSchema<ComponentType>;
 export type { VbenFormProps };

+ 7 - 4
apps/web-baicai/src/api/core/auth.ts

@@ -4,8 +4,8 @@ import { encrypt } from '#/utils';
 export namespace AuthApi {
   /** 登录接口参数 */
   export interface LoginParams {
-    password?: string;
-    username?: string;
+    password: string;
+    username: string;
   }
 
   /** 登录接口返回值 */
@@ -23,8 +23,11 @@ export namespace AuthApi {
  * 登录
  */
 export async function loginApi(data: AuthApi.LoginParams) {
-  data.password = encrypt(data.password);
-  return requestClient.post<AuthApi.LoginResult>('/security/login', data);
+  const postData = {
+    password: encrypt(data.password),
+    username: data.username,
+  };
+  return requestClient.post<AuthApi.LoginResult>('/security/login', postData);
 }
 
 /**

+ 6 - 0
apps/web-baicai/src/api/model/index.ts

@@ -21,6 +21,7 @@ export interface TransferOptionResult {
 export interface BasicOptionResult {
   label: string;
   value: number | string;
+  disabled?: boolean;
 }
 
 export interface BasicTreeOptionResult extends BasicOptionResult {
@@ -48,6 +49,11 @@ export const formatterStatus = ({
   return '';
 };
 
+export const boolOptions: BasicOptionResult[] = [
+  { label: '是', value: 1 },
+  { label: '否', value: 0 },
+];
+
 export const dbTypeOptions: BasicOptionResult[] = [
   { label: 'MySql', value: 0 },
   { label: 'SqlServer', value: 1 },

+ 8 - 4
apps/web-baicai/src/api/request.ts

@@ -74,12 +74,12 @@ function createRequestClient(baseURL: string) {
     fulfilled: (response) => {
       const { data: responseData, status } = response;
 
-      const { code, data, message: msg } = responseData;
+      const { code, data } = responseData;
 
       if (status >= 200 && status < 400 && code === 200) {
         return data;
       }
-      throw new Error(`Error ${status}: ${msg}`);
+      throw Object.assign({}, response, { response });
     },
   });
 
@@ -96,9 +96,13 @@ function createRequestClient(baseURL: string) {
 
   // 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
   client.addResponseInterceptor(
-    errorMessageResponseInterceptor((msg: string, _error) => {
+    errorMessageResponseInterceptor((msg: string, error) => {
       // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
-      message.error(msg);
+      // 当前mock接口返回的错误字段是 error 或者 message
+      const responseData = error?.response?.data ?? {};
+      const errorMessage = responseData?.error ?? responseData?.message ?? '';
+      // 如果没有错误信息,则会根据状态码进行提示
+      message.error(errorMessage || msg);
     }),
   );
 

+ 3 - 0
apps/web-baicai/src/api/system/enum.ts

@@ -4,6 +4,9 @@ import { requestClient } from '#/api/request';
 
 export namespace EnumApi {
   export enum EnumType {
+    MenuType = 'MenuType',
+    PathType = 'PathType',
+    Status = 'Status',
     TenantType = 'TenantType',
   }
 

+ 1 - 0
apps/web-baicai/src/api/system/index.ts

@@ -1,4 +1,5 @@
 export * from './database';
 export * from './enum';
+export * from './menu';
 export * from './query';
 export * from './tenant';

+ 39 - 0
apps/web-baicai/src/api/system/menu.ts

@@ -0,0 +1,39 @@
+import { requestClient } from '#/api/request';
+
+export namespace MenuApi {
+  export interface PageParams {
+    title?: string;
+    type?: number;
+  }
+
+  export interface BasicRecordItem {
+    title: string;
+    icon: string;
+    permission: string;
+    path: string;
+    component: string;
+    sort: number;
+    status: number;
+  }
+
+  export interface RecordItem extends BasicRecordItem {
+    id: number;
+  }
+
+  export const getList = (params: PageParams) =>
+    requestClient.get<RecordItem[]>('/menu/list', { params });
+
+  export const getDetail = (id: number) =>
+    requestClient.get<RecordItem>('/menu/entity', {
+      params: { id },
+    });
+
+  export const addDetail = (data: BasicRecordItem) =>
+    requestClient.post('/menu', data);
+
+  export const editDetail = (data: RecordItem) =>
+    requestClient.put('/menu', data);
+
+  export const deleteDetail = (id: number) =>
+    requestClient.delete('/menu', { data: { id } });
+}

+ 4 - 5
apps/web-baicai/src/bootstrap.ts

@@ -5,14 +5,16 @@ import { initStores } from '@vben/stores';
 import '@vben/styles';
 import '@vben/styles/antd';
 
-import { VueQueryPlugin } from '@tanstack/vue-query';
-
 import { setupI18n } from '#/locales';
 
+import { initComponentAdapter } from './adapter/component';
 import App from './app.vue';
 import { router } from './router';
 
 async function bootstrap(namespace: string) {
+  // 初始化组件适配器
+  await initComponentAdapter();
+
   const app = createApp(App);
 
   // 国际化 i18n 配置
@@ -27,9 +29,6 @@ async function bootstrap(namespace: string) {
   // 配置路由及路由守卫
   app.use(router);
 
-  // 配置@tanstack/vue-query
-  app.use(VueQueryPlugin);
-
   app.mount('#app');
 }
 

+ 27 - 0
apps/web-baicai/src/components/form/component-map.ts

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

+ 131 - 0
apps/web-baicai/src/components/form/components/api-checkbox.vue

@@ -0,0 +1,131 @@
+<script setup lang="ts">
+import type { CheckboxValueType } from 'ant-design-vue/es/checkbox/interface';
+
+import type { ApiConfig } from '../types';
+
+import type { BasicOptionResult } from '#/api/model';
+
+import { computed, type PropType, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { CheckboxGroup, Spin } from 'ant-design-vue';
+
+import { EnumApi, QueryApi } from '#/api';
+import { requestClient } from '#/api/request';
+import { get, omit } from '#/utils';
+
+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: any =
+    typeof props.api.url === 'string' || !props.api.url
+      ? (params: any) => {
+          if (props.api.type === 'enum') {
+            return EnumApi.getList(params);
+          } else if (props.api.type === 'api') {
+            return QueryApi.postExecuteReal(params);
+          } else
+            return (requestClient as any)[props.api.method](
+              props.api.url as any,
+              params,
+            );
+        }
+      : props.api.url;
+  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>

+ 137 - 0
apps/web-baicai/src/components/form/components/api-radio.vue

@@ -0,0 +1,137 @@
+<script setup lang="ts">
+import type { SelectValue } from 'ant-design-vue/es/select';
+
+import type { ApiConfig } from '../types';
+
+import type { BasicOptionResult } from '#/api/model';
+
+import { computed, type PropType, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { RadioGroup, Spin } from 'ant-design-vue';
+
+import { EnumApi, QueryApi } from '#/api';
+import { requestClient } from '#/api/request';
+import { get, omit } from '#/utils';
+
+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: any =
+    typeof props.api.url === 'string' || !props.api.url
+      ? (params: any) => {
+          if (props.api.type === 'enum') {
+            return EnumApi.getList(params);
+          } else if (props.api.type === 'api') {
+            return QueryApi.postExecuteReal(params);
+          } else
+            return (requestClient as any)[props.api.method](
+              props.api.url as any,
+              params,
+            );
+        }
+      : props.api.url;
+  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>

+ 147 - 0
apps/web-baicai/src/components/form/components/api-select.vue

@@ -0,0 +1,147 @@
+<script setup lang="ts">
+import type { SelectValue } from 'ant-design-vue/es/select';
+
+import type { ApiConfig } from '../types';
+
+import type { BasicOptionResult } from '#/api/model';
+
+import { computed, type PropType, ref, unref, watch, watchEffect } from 'vue';
+
+import { isFunction } from '@vben/utils';
+
+import { useVModel } from '@vueuse/core';
+import { Select } from 'ant-design-vue';
+
+import { EnumApi, QueryApi } from '#/api';
+import { requestClient } from '#/api/request';
+import { Icon } from '#/components/icon';
+import { get, omit } from '#/utils';
+
+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,
+  },
+});
+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(options));
+};
+
+const fetch = async () => {
+  const api: any =
+    typeof props.api.url === 'string' || !props.api.url
+      ? (params: any) => {
+          if (props.api.type === 'enum') {
+            return EnumApi.getList(params);
+          } else if (props.api.type === 'api') {
+            return QueryApi.postExecuteReal(params);
+          } else
+            return (requestClient as any)[props.api.method](
+              props.api.url as any,
+              params,
+            );
+        }
+      : props.api.url;
+  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;
+  }
+};
+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"
+    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>

+ 13 - 0
apps/web-baicai/src/components/form/types/index.d.ts

@@ -0,0 +1,13 @@
+export type CustomComponentType =
+  | 'ApiCheckbox'
+  | 'ApiRadio'
+  | 'ApiSelect'
+  | 'ApiTreeSelect';
+
+export type ApiConfig = {
+  method: string;
+  params: PropType<object>;
+  result: string;
+  type: 'api' | 'dict' | 'enum' | 'none';
+  url: PropType<(arg?: any) => Promise<BasicOptionResult[]> | string>;
+};

+ 19 - 0
apps/web-baicai/src/components/icon/icon.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+
+import { createIconifyIcon } from '@vben/icons';
+
+const props = defineProps({
+  icon: {
+    type: String,
+    default: '',
+  },
+});
+const iconComp = computed(() => {
+  return createIconifyIcon(props.icon);
+});
+</script>
+
+<template>
+  <component :is="iconComp" v-if="iconComp" />
+</template>

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

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

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

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

+ 177 - 0
apps/web-baicai/src/components/table-action/table-action.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import type { ButtonType } from 'ant-design-vue/es/button';
+
+import type { ActionItem, PopConfirm } from './types';
+
+import { computed, type PropType, 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;
+}
+
+const getActions = computed(() => {
+  return (toRaw(props.actions) || [])
+    .filter((action) => {
+      return (
+        (hasAccessByCodes(action.auth || []) ||
+          (action.auth || []).length === 0) &&
+        isIfShow(action)
+      );
+    })
+    .map((action) => {
+      const { popConfirm } = action;
+      return {
+        // getPopupContainer: document.body,
+        type: 'link' as ButtonType,
+        ...action,
+        ...popConfirm,
+        onConfirm: popConfirm?.confirm,
+        onCancel: popConfirm?.cancel,
+        enable: !!popConfirm,
+      };
+    });
+});
+const getDropdownList = computed((): any[] => {
+  return (toRaw(props.dropDownActions) || [])
+    .filter((action) => {
+      return (
+        (hasAccessByCodes(action.auth || []) ||
+          (action.auth || []).length === 0) &&
+        isIfShow(action)
+      );
+    })
+    .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,
+      };
+    });
+});
+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)">
+            <Icon v-if="action.icon" :icon="action.icon" />
+            {{ action.label }}
+          </Button>
+        </Popconfirm>
+        <Button v-else v-bind="getButtonProps(action)" @click="action.onClick">
+          <Icon v-if="action.icon" :icon="action.icon" />
+          {{ action.label }}
+        </Button>
+      </template>
+    </Space>
+
+    <Dropdown v-if="getDropdownList.length > 0" :trigger="['hover']">
+      <slot name="more">
+        <Button size="small" type="text">
+          <Icon class="icon-more size-5" icon="ic:twotone-more-horiz" />
+        </Button>
+      </slot>
+      <template #overlay>
+        <Menu @click="handleMenuClick">
+          <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>
+                  <Icon v-if="action.icon" :icon="action.icon" />
+                  <span class="ml-1">{{ action.text }}</span>
+                </div>
+              </Popconfirm>
+            </template>
+            <template v-else>
+              <Icon v-if="action.icon" :icon="action.icon" />
+              {{ action.label }}
+            </template>
+          </MenuItem>
+        </Menu>
+      </template>
+    </Dropdown>
+  </div>
+</template>

+ 25 - 0
apps/web-baicai/src/components/table-action/types.d.ts

@@ -0,0 +1,25 @@
+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;
+}
+export interface ActionItem extends ButtonProps {
+  onClick?: Fn;
+  label?: string;
+  color?: 'error' | 'success' | 'warning';
+  icon?: string;
+  popConfirm?: PopConfirm;
+  disabled?: boolean;
+  divider?: boolean;
+  // 权限编码控制是否显示
+  auth?: string[];
+  // 业务控制是否显示
+  ifShow?: ((action: ActionItem) => boolean) | boolean;
+  tooltip?: string | TooltipProps;
+}

+ 1 - 0
apps/web-baicai/src/utils/index.ts

@@ -1 +1,2 @@
 export * from './cryptogram';
+export * from './utils';

+ 69 - 0
apps/web-baicai/src/utils/utils.ts

@@ -0,0 +1,69 @@
+/**
+ * -转大驼峰
+ * @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('');
+};
+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;
+};

+ 11 - 9
apps/web-baicai/src/views/_core/authentication/login.vue

@@ -1,9 +1,9 @@
 <script lang="ts" setup>
 import type { VbenFormSchema } from '@vben/common-ui';
 
-import { computed, markRaw } from 'vue';
+import { computed } from 'vue';
 
-import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
+import { AuthenticationLogin, z } from '@vben/common-ui';
 import { $t } from '@vben/locales';
 
 import { useAuthStore } from '#/store';
@@ -32,13 +32,13 @@ const formSchema = computed((): VbenFormSchema[] => {
       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'),
-      }),
-    },
+    // {
+    //   component: markRaw(SliderCaptcha),
+    //   fieldName: 'captcha',
+    //   rules: z.boolean().refine((value) => value, {
+    //     message: $t('authentication.verifyRequiredTip'),
+    //   }),
+    // },
   ];
 });
 </script>
@@ -47,7 +47,9 @@ const formSchema = computed((): VbenFormSchema[] => {
   <AuthenticationLogin
     :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"

+ 8 - 21
apps/web-baicai/src/views/system/design/query/components/stepBaseConfig.vue

@@ -1,18 +1,10 @@
 <script lang="ts" setup>
-import type { BasicOptionResult } from '#/api/model';
-
-import { onMounted, reactive } from 'vue';
+import { onMounted } from 'vue';
 
 import { useVbenForm } from '#/adapter';
 import { TenantApi } from '#/api';
 
-const state = reactive<{
-  dbBaseOptions: BasicOptionResult[];
-}>({
-  dbBaseOptions: [],
-});
-
-const [Form, { validate, setValues, getValues, updateSchema }] = useVbenForm({
+const [Form, { validate, setValues, getValues }] = useVbenForm({
   commonConfig: {
     componentProps: {
       class: 'w-full',
@@ -38,10 +30,13 @@ const [Form, { validate, setValues, getValues, updateSchema }] = useVbenForm({
       rules: 'required',
     },
     {
-      component: 'Select',
+      component: 'ApiSelect',
       componentProps: {
         placeholder: '请输入',
-        options: [],
+        api: {
+          url: TenantApi.getOptions,
+        },
+        numberToString: true,
       },
       fieldName: 'configId',
       label: '数据库',
@@ -83,15 +78,7 @@ const stepGetValues = async () => {
   return await getValues();
 };
 
-onMounted(async () => {
-  state.dbBaseOptions = await TenantApi.getOptions();
-  updateSchema([
-    {
-      fieldName: 'configId',
-      componentProps: { options: state.dbBaseOptions },
-    },
-  ]);
-});
+onMounted(async () => {});
 
 defineExpose({ stepSetValues, stepValidate, stepGetValues });
 </script>

+ 8 - 1
apps/web-baicai/src/views/system/design/query/data.config.ts

@@ -19,6 +19,13 @@ export const searchFormOptions: VbenFormProps = {
 };
 
 export const gridOptions: VxeGridProps<QueryApi.RecordItem> = {
+  toolbarConfig: {
+    refresh: true,
+    print: false,
+    export: false,
+    zoom: true,
+    custom: true,
+  },
   columns: [
     { title: '序号', type: 'seq', width: 50 },
     {
@@ -35,7 +42,7 @@ export const gridOptions: VxeGridProps<QueryApi.RecordItem> = {
       fixed: 'right',
       slots: { default: 'action' },
       title: '操作',
-      width: 180,
+      width: 140,
     },
   ],
   height: 'auto',

+ 26 - 36
apps/web-baicai/src/views/system/design/query/index.vue

@@ -1,12 +1,12 @@
 <script lang="ts" setup>
 import { useAccess } from '@vben/access';
 import { Page, useVbenModal } from '@vben/common-ui';
-import { More } from '@vben/icons';
 
-import { Button, Dropdown, Menu, message, Modal } from 'ant-design-vue';
+import { Button, message, Modal } from 'ant-design-vue';
 
 import { useVbenVxeGrid } from '#/adapter';
 import { QueryApi } from '#/api';
+import { TableAction } from '#/components/table-action';
 
 import FormEdit from './components/edit.vue';
 import FormView from './components/view.vue';
@@ -75,40 +75,30 @@ const handelSuccess = () => {
         </Button>
       </template>
       <template #action="{ row }">
-        <div class="flex items-center">
-          <Button
-            :disabled="!hasAccessByCodes(['query:edit'])"
-            type="link"
-            @click="() => handleEdit(row, true)"
-          >
-            编辑
-          </Button>
-          <Button
-            :disabled="!hasAccessByCodes(['query:view'])"
-            danger
-            type="link"
-            @click="() => handleView(row.id)"
-          >
-            查看
-          </Button>
-
-          <Dropdown>
-            <a class="ant-dropdown-link" @click.prevent>
-              <More class="size-4" />
-            </a>
-            <template #overlay>
-              <Menu>
-                <Menu.Item
-                  key="0"
-                  :disabled="!hasAccessByCodes(['query:delete'])"
-                  @click="() => handleDelete(row.id)"
-                >
-                  <a href="#">删除</a>
-                </Menu.Item>
-              </Menu>
-            </template>
-          </Dropdown>
-        </div>
+        <TableAction
+          :actions="[
+            {
+              label: '编辑',
+              type: 'text',
+              disabled: !hasAccessByCodes(['query:edit']),
+              onClick: handleEdit.bind(null, row, true),
+            },
+            {
+              label: '查看',
+              type: 'text',
+              disabled: !hasAccessByCodes(['query:view']),
+              onClick: handleView.bind(null, row.id),
+            },
+          ]"
+          :drop-down-actions="[
+            {
+              label: '删除',
+              type: 'link',
+              disabled: !hasAccessByCodes(['query:delete']),
+              onClick: handleDelete.bind(null, row.id),
+            },
+          ]"
+        />
       </template>
     </Grid>
   </Page>

+ 78 - 0
apps/web-baicai/src/views/system/menu/components/edit.vue

@@ -0,0 +1,78 @@
+<script lang="ts" setup>
+import { onMounted, ref, unref } from 'vue';
+
+import { useVbenDrawer } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter';
+import { MenuApi } from '#/api';
+
+import { formOptions } from '../data.config';
+
+defineOptions({
+  name: 'MenuEdit',
+});
+const emit = defineEmits(['success']);
+const modelRef = ref<Record<string, any>>({});
+const isUpdate = ref(true);
+
+const [Form, { validate, setValues, getValues }] = useVbenForm({
+  showDefaultActions: false,
+  ...formOptions,
+});
+
+const [Drawer, { close, setState, getData }] = useVbenDrawer({
+  // fullscreenButton: false,
+  // draggable: true,
+  onCancel() {
+    close();
+  },
+  onConfirm: async () => {
+    try {
+      const { valid } = await validate();
+      if (valid) {
+        const values = await getValues();
+        setState({ confirmLoading: true });
+        const postParams = unref(modelRef);
+        Object.assign(postParams, values);
+        await (unref(isUpdate)
+          ? MenuApi.editDetail(postParams as MenuApi.RecordItem)
+          : MenuApi.addDetail(postParams as MenuApi.BasicRecordItem));
+        message.success('操作成功');
+
+        close();
+        emit('success');
+      }
+    } catch {
+      message.error('操作失败');
+    } finally {
+      setState({ confirmLoading: false });
+    }
+  },
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+      isUpdate.value = !!data.isUpdate;
+      modelRef.value = { ...data.baseData };
+      setState({ title: unref(isUpdate) ? '编辑菜单' : '新增菜单' });
+
+      if (unref(isUpdate)) {
+        const entity = await MenuApi.getDetail(data.baseData.id);
+        modelRef.value = { ...entity };
+        setValues(entity);
+      }
+      setState({ loading: false });
+    }
+  },
+  title: '新增菜单',
+});
+
+onMounted(async () => {});
+</script>
+<template>
+  <Drawer class="w-[1000px]">
+    <Form />
+  </Drawer>
+</template>

+ 305 - 0
apps/web-baicai/src/views/system/menu/data.config.ts

@@ -0,0 +1,305 @@
+import type { VbenFormProps, VxeGridProps } from '#/adapter';
+
+import { h } from 'vue';
+
+import { EnumApi, MenuApi } from '#/api';
+import { boolOptions, formatterStatus } from '#/api/model';
+import { Icon } from '#/components/icon';
+
+export const searchFormOptions: VbenFormProps = {
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'title',
+      label: '标题',
+    },
+    {
+      component: 'ApiSelect',
+      fieldName: 'type',
+      label: '菜单类型',
+      componentProps: {
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.MenuType,
+        },
+      },
+    },
+  ],
+};
+
+export const gridOptions: VxeGridProps<MenuApi.RecordItem> = {
+  toolbarConfig: {
+    refresh: true,
+    print: false,
+    export: false,
+    zoom: true,
+    custom: true,
+  },
+  columns: [
+    { title: '序号', type: 'seq', width: 50 },
+    {
+      align: 'left',
+      field: 'title',
+      title: '菜单名称',
+      width: 200,
+      treeNode: true,
+      showOverflow: true,
+      slots: {
+        default: ({ row }) => {
+          return row.icon
+            ? h(
+                'span',
+                {
+                  style: {
+                    display: 'flex',
+                    alignItems: 'center',
+                  },
+                },
+                [
+                  h(Icon, {
+                    icon: row.icon,
+                  }),
+                  h(
+                    'span',
+                    {
+                      style: {
+                        paddingLeft: '6px',
+                      },
+                    },
+                    row.title,
+                  ),
+                ],
+              )
+            : h('span', {}, row.title);
+        },
+      },
+    },
+    { align: 'left', field: 'path', title: '路由地址', width: 180 },
+    { align: 'left', field: 'component', title: '组件' },
+    {
+      align: 'left',
+      field: 'permission',
+      title: '权限标识',
+      width: 120,
+      showOverflow: true,
+    },
+    {
+      field: 'status',
+      title: '状态',
+      width: 60,
+      formatter: formatterStatus,
+    },
+    { field: 'sort', title: '排序', width: 50 },
+    {
+      field: 'action',
+      fixed: 'right',
+      slots: { default: 'action' },
+      title: '操作',
+      width: 160,
+    },
+  ],
+  height: 'auto',
+  keepSource: true,
+  pagerConfig: {
+    enabled: false,
+  },
+  treeConfig: {
+    rowField: 'id',
+    childrenField: 'children',
+  },
+  proxyConfig: {
+    ajax: {
+      query: async ({ page }, formValues) => {
+        return await MenuApi.getList({
+          pageIndex: page.currentPage,
+          pageSize: page.pageSize,
+          ...formValues,
+        });
+      },
+    },
+  },
+};
+
+export const formOptions: VbenFormProps = {
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+      labelWidth: 110,
+    },
+  },
+  schema: [
+    {
+      component: 'ApiRadio',
+      defaultValue: 0,
+      componentProps: {
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.MenuType,
+        },
+        isBtn: true,
+      },
+      fieldName: 'type',
+      label: '菜单类型',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入路由名称',
+      },
+      fieldName: 'name',
+      label: '路由名称',
+      rules: 'required',
+      dependencies: {
+        show(values) {
+          return [0, 1].includes(values.type);
+        },
+        triggerFields: ['type'],
+      },
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入路由地址',
+      },
+      fieldName: 'path',
+      label: '路由地址',
+      rules: 'required',
+      dependencies: {
+        triggerFields: ['type'],
+        show(values) {
+          return [0, 1].includes(values.type);
+        },
+      },
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入组件路径',
+      },
+      dependencies: {
+        show(values) {
+          return [0, 1].includes(values.type);
+        },
+        triggerFields: ['type'],
+      },
+      fieldName: 'component',
+      label: '组件路径',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入重定向',
+      },
+      dependencies: {
+        triggerFields: ['type'],
+        show(values) {
+          return [0, 1].includes(values.type);
+        },
+      },
+      fieldName: 'redirect',
+      label: '重定向',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入菜单名称',
+      },
+      fieldName: 'title',
+      label: '菜单名称',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入图标',
+      },
+      fieldName: 'icon',
+      label: '图标',
+      rules: 'required',
+      dependencies: {
+        show(values) {
+          return [0, 1].includes(values.type);
+        },
+        triggerFields: ['type'],
+      },
+    },
+    {
+      component: 'RadioGroup',
+      defaultValue: 1,
+      componentProps: {
+        placeholder: '请输入',
+        options: boolOptions,
+        optionType: 'button',
+        buttonStyle: 'solid',
+      },
+      fieldName: 'keepAlive',
+      label: '缓存',
+    },
+    {
+      component: 'RadioGroup',
+      defaultValue: 0,
+      componentProps: {
+        placeholder: '请输入',
+        options: boolOptions,
+        optionType: 'button',
+        buttonStyle: 'solid',
+      },
+      fieldName: 'hideInTab',
+      label: '隐藏',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        placeholder: '请输入',
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.PathType,
+        },
+      },
+      fieldName: 'pathType',
+      label: '路由类型',
+      rules: 'required',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'sort',
+      label: '排序',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      dependencies: {
+        show(values) {
+          return [2].includes(values.type);
+        },
+        triggerFields: ['type'],
+      },
+      fieldName: 'permission',
+      label: '权限标识',
+    },
+    {
+      component: 'ApiRadio',
+      defaultValue: 1,
+      componentProps: {
+        placeholder: '请输入',
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.Status,
+        },
+        isBtn: true,
+      },
+      fieldName: 'status',
+      label: '状态',
+    },
+  ],
+  showDefaultActions: false,
+  wrapperClass: 'grid-cols-1',
+};

+ 96 - 0
apps/web-baicai/src/views/system/menu/index.vue

@@ -0,0 +1,96 @@
+<script lang="ts" setup>
+import { useAccess } from '@vben/access';
+import { Page, useVbenDrawer } from '@vben/common-ui';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter';
+import { MenuApi } from '#/api';
+import { TableAction } from '#/components/table-action';
+
+import FormEdit from './components/edit.vue';
+import { gridOptions, searchFormOptions } from './data.config';
+
+const { hasAccessByCodes } = useAccess();
+
+const [Grid, { reload }] = useVbenVxeGrid({
+  formOptions: searchFormOptions,
+  gridOptions,
+});
+
+const [FormEditModal, formEditApi] = useVbenDrawer({
+  connectedComponent: FormEdit,
+});
+
+const handleDelete = (id: number) => {
+  Modal.confirm({
+    iconType: 'info',
+    title: '删除提示',
+    content: `确定要删除选择的记录吗?`,
+    cancelText: `关闭`,
+    onOk: async () => {
+      await MenuApi.deleteDetail(id);
+      message.success('数据删除成功');
+      reload();
+    },
+  });
+};
+
+const handleEdit = (record: any, isUpdate: boolean) => {
+  formEditApi.setData({
+    isUpdate,
+    baseData: { ...record },
+  });
+
+  formEditApi.open();
+};
+
+const handelSuccess = () => {
+  reload();
+};
+</script>
+
+<template>
+  <Page auto-content-height>
+    <FormEditModal :close-on-click-modal="false" @success="handelSuccess" />
+    <Grid>
+      <template #toolbar-tools>
+        <Button
+          class="mr-2"
+          type="primary"
+          v-access:code="'menu:add'"
+          @click="() => handleEdit({}, false)"
+        >
+          新增菜单
+        </Button>
+      </template>
+      <template #action="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '添加项',
+              type: 'text',
+              disabled: !hasAccessByCodes(['menu:add']),
+              onClick: handleEdit.bind(null, { pid: row.id }, false),
+            },
+            {
+              label: '编辑',
+              type: 'text',
+              danger: true,
+              disabled: !hasAccessByCodes(['menu:edit']),
+              onClick: handleEdit.bind(null, { id: row.id }, true),
+            },
+          ]"
+          :drop-down-actions="[
+            {
+              label: '删除',
+              type: 'link',
+              disabled: !hasAccessByCodes(['menu:delete']),
+              onClick: handleDelete.bind(null, row.id),
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 7 - 126
apps/web-baicai/src/views/system/tenant/components/edit.vue

@@ -1,13 +1,14 @@
 <script lang="ts" setup>
-import { onMounted, reactive, ref, unref } from 'vue';
+import { onMounted, ref, unref } from 'vue';
 
 import { useVbenModal } from '@vben/common-ui';
 
 import { message } from 'ant-design-vue';
 
 import { useVbenForm } from '#/adapter';
-import { EnumApi, TenantApi } from '#/api';
-import { type BasicOptionResult, dbTypeOptions } from '#/api/model';
+import { TenantApi } from '#/api';
+
+import { formOptions } from '../data.config';
 
 defineOptions({
   name: 'TenantEdit',
@@ -16,118 +17,9 @@ const emit = defineEmits(['success']);
 const modelRef = ref<Record<string, any>>({});
 const isUpdate = ref(true);
 
-const state = reactive<{
-  tenantTypeOptions: BasicOptionResult[];
-}>({
-  tenantTypeOptions: [],
-});
-
-const [Form, { validate, setValues, getValues, updateSchema }] = useVbenForm({
-  commonConfig: {
-    componentProps: {
-      class: 'w-full',
-    },
-  },
-  schema: [
-    {
-      component: 'RadioGroup',
-      componentProps: {
-        options: [],
-      },
-      fieldName: 'tenantType',
-      label: '租户类型',
-      rules: 'required',
-    },
-    {
-      component: 'Input',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      fieldName: 'name',
-      label: '租户名称',
-      rules: 'required',
-    },
-    {
-      component: 'Input',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      fieldName: 'adminAccount',
-      label: '管理员',
-      rules: 'required',
-    },
-    {
-      component: 'Input',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      fieldName: 'phone',
-      label: '联系电话',
-      rules: 'required',
-    },
-    {
-      component: 'Input',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      fieldName: 'email',
-      label: '电子邮箱',
-      rules: 'required',
-    },
-    {
-      component: 'InputNumber',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      fieldName: 'sort',
-      label: '排序',
-      rules: 'required',
-    },
-    {
-      component: 'Select',
-      componentProps: {
-        placeholder: '请输入',
-        options: dbTypeOptions,
-      },
-      dependencies: {
-        show(values) {
-          return values.tenantType === 1;
-        },
-        triggerFields: ['tenantType'],
-      },
-      fieldName: 'dbType',
-      label: '数据库类型',
-      rules: 'required',
-      formItemClass: 'col-span-2 items-baseline',
-    },
-    {
-      component: 'Textarea',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      dependencies: {
-        show(values) {
-          return values.tenantType === 1;
-        },
-        triggerFields: ['tenantType'],
-      },
-      fieldName: 'connection',
-      label: '连接字符串',
-      rules: 'required',
-      formItemClass: 'col-span-2 items-baseline',
-    },
-    {
-      component: 'Input',
-      componentProps: {
-        placeholder: '请输入',
-      },
-      fieldName: 'remark',
-      label: '备注',
-      formItemClass: 'col-span-2 items-baseline',
-    },
-  ],
+const [Form, { validate, setValues, getValues }] = useVbenForm({
   showDefaultActions: false,
-  wrapperClass: 'grid-cols-2',
+  ...formOptions,
 });
 
 const [Modal, { close, setState, getData }] = useVbenModal({
@@ -177,18 +69,7 @@ const [Modal, { close, setState, getData }] = useVbenModal({
   title: '新增租户',
 });
 
-onMounted(async () => {
-  state.tenantTypeOptions = await EnumApi.getList(EnumApi.EnumType.TenantType);
-
-  updateSchema([
-    {
-      fieldName: 'tenantType',
-      componentProps: {
-        options: state.tenantTypeOptions,
-      },
-    },
-  ]);
-});
+onMounted(async () => {});
 </script>
 <template>
   <Modal class="w-[1000px]">

+ 120 - 4
apps/web-baicai/src/views/system/tenant/data.config.ts

@@ -1,7 +1,7 @@
 import type { VbenFormProps, VxeGridProps } from '#/adapter';
 
-import { TenantApi } from '#/api';
-import { formatterStatus } from '#/api/model';
+import { EnumApi, TenantApi } from '#/api';
+import { dbTypeOptions, formatterStatus } from '#/api/model';
 
 export const searchFormOptions: VbenFormProps = {
   schema: [
@@ -23,6 +23,13 @@ export const gridOptions: VxeGridProps<TenantApi.RecordItem> = {
     highlight: true,
     labelField: 'name',
   },
+  toolbarConfig: {
+    refresh: true,
+    print: false,
+    export: false,
+    zoom: true,
+    custom: true,
+  },
   columns: [
     { title: '序号', type: 'seq', width: 50 },
     {
@@ -48,12 +55,11 @@ export const gridOptions: VxeGridProps<TenantApi.RecordItem> = {
       fixed: 'right',
       slots: { default: 'action' },
       title: '操作',
-      width: 180,
+      width: 160,
     },
   ],
   height: 'auto',
   keepSource: true,
-  pagerConfig: {},
   proxyConfig: {
     ajax: {
       query: async ({ page }, formValues) => {
@@ -66,3 +72,113 @@ export const gridOptions: VxeGridProps<TenantApi.RecordItem> = {
     },
   },
 };
+
+export const formOptions: VbenFormProps = {
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+  },
+  schema: [
+    {
+      component: 'ApiRadio',
+      componentProps: {
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.TenantType,
+        },
+      },
+      fieldName: 'tenantType',
+      label: '租户类型',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'name',
+      label: '租户名称',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'adminAccount',
+      label: '管理员',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'phone',
+      label: '联系电话',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'email',
+      label: '电子邮箱',
+      rules: 'required',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'sort',
+      label: '排序',
+      rules: 'required',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        placeholder: '请输入',
+        options: dbTypeOptions,
+      },
+      dependencies: {
+        show(values) {
+          return values.tenantType === 1;
+        },
+        triggerFields: ['tenantType'],
+      },
+      fieldName: 'dbType',
+      label: '数据库类型',
+      rules: 'required',
+      formItemClass: 'col-span-2 items-baseline',
+    },
+    {
+      component: 'Textarea',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      dependencies: {
+        show(values) {
+          return values.tenantType === 1;
+        },
+        triggerFields: ['tenantType'],
+      },
+      fieldName: 'connection',
+      label: '连接字符串',
+      rules: 'required',
+      formItemClass: 'col-span-2 items-baseline',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'remark',
+      label: '备注',
+      formItemClass: 'col-span-2 items-baseline',
+    },
+  ],
+  wrapperClass: 'grid-cols-2',
+};

+ 33 - 43
apps/web-baicai/src/views/system/tenant/index.vue

@@ -1,12 +1,12 @@
 <script lang="ts" setup>
 import { useAccess } from '@vben/access';
 import { Page, useVbenModal } from '@vben/common-ui';
-import { More } from '@vben/icons';
 
-import { Button, Dropdown, Menu, message, Modal } from 'ant-design-vue';
+import { Button, message, Modal } from 'ant-design-vue';
 
 import { useVbenVxeGrid } from '#/adapter';
 import { TenantApi } from '#/api';
+import { TableAction } from '#/components/table-action';
 
 import FormEdit from './components/edit.vue';
 import { gridOptions, searchFormOptions } from './data.config';
@@ -78,47 +78,37 @@ const handelSuccess = () => {
         </Button>
       </template>
       <template #action="{ row }">
-        <div class="flex">
-          <Button
-            :disabled="
-              !hasAccessByCodes(['tenant:createDb']) || row.tenantType === 0
-            "
-            type="link"
-            @click="() => handleCreateDb(row.id)"
-          >
-            创建库
-          </Button>
-          <Button
-            :disabled="!hasAccessByCodes(['tenant:edit'])"
-            danger
-            type="link"
-            @click="() => handleEdit(row, true)"
-          >
-            修改
-          </Button>
-          <Dropdown>
-            <a class="ant-dropdown-link" @click.prevent>
-              <More class="size-4" />
-            </a>
-            <template #overlay>
-              <Menu>
-                <Menu.Item
-                  key="0"
-                  :disabled="!hasAccessByCodes(['tenant:resetPwd'])"
-                >
-                  <a href="#">重置密码</a>
-                </Menu.Item>
-                <Menu.Item
-                  key="1"
-                  :disabled="!hasAccessByCodes(['tenant:delete'])"
-                  @click="() => handleDelete(row.id)"
-                >
-                  <a href="#">删除</a>
-                </Menu.Item>
-              </Menu>
-            </template>
-          </Dropdown>
-        </div>
+        <TableAction
+          :actions="[
+            {
+              label: '创建库',
+              type: 'text',
+              disabled:
+                !hasAccessByCodes(['tenant:createDb']) || row.tenantType === 0,
+              onClick: handleCreateDb.bind(null, row),
+            },
+            {
+              label: '编辑',
+              type: 'text',
+              danger: true,
+              disabled: !hasAccessByCodes(['tenant:edit']),
+              onClick: handleEdit.bind(null, row, true),
+            },
+          ]"
+          :drop-down-actions="[
+            {
+              label: '重置密码',
+              type: 'link',
+              disabled: !hasAccessByCodes(['tenant:resetPwd']),
+            },
+            {
+              label: '删除',
+              type: 'link',
+              disabled: !hasAccessByCodes(['tenant:delete']),
+              onClick: handleDelete.bind(null, row.id),
+            },
+          ]"
+        />
       </template>
     </Grid>
   </Page>