瀏覽代碼

feat: 添加个人中心

DESKTOP-USV654P\pc 1 年之前
父節點
當前提交
ad3c6dd421
共有 28 個文件被更改,包括 1615 次插入37 次删除
  1. 21 1
      apps/web-baicai/src/api/model/index.ts
  2. 4 5
      apps/web-baicai/src/api/request.ts
  3. 61 0
      apps/web-baicai/src/api/system/database.ts
  4. 6 0
      apps/web-baicai/src/api/system/menu.ts
  5. 3 0
      apps/web-baicai/src/api/system/user.ts
  6. 1 1
      apps/web-baicai/src/components/form/components/api-popup/api-popup-modal-ignore.vue
  7. 37 4
      apps/web-baicai/src/components/icon/icon.vue
  8. 5 1
      apps/web-baicai/src/components/table-action/src/table-action.vue
  9. 11 0
      apps/web-baicai/src/layouts/basic.vue
  10. 22 1
      apps/web-baicai/src/router/routes/core.ts
  11. 181 0
      apps/web-baicai/src/views/system/design/database/components/edit.vue
  12. 74 0
      apps/web-baicai/src/views/system/design/database/components/editColumn.vue
  13. 258 0
      apps/web-baicai/src/views/system/design/database/data.config.ts
  14. 109 0
      apps/web-baicai/src/views/system/design/database/index.vue
  15. 22 5
      apps/web-baicai/src/views/system/design/table/components/page.vue
  16. 27 0
      apps/web-baicai/src/views/system/design/table/index.vue
  17. 38 0
      apps/web-baicai/src/views/system/design/template/index.vue
  18. 20 3
      apps/web-baicai/src/views/system/menu/components/edit.vue
  19. 97 0
      apps/web-baicai/src/views/system/menu/components/editMenu.vue
  20. 3 3
      apps/web-baicai/src/views/system/menu/components/grant.vue
  21. 220 8
      apps/web-baicai/src/views/system/menu/data.config.ts
  22. 59 0
      apps/web-baicai/src/views/system/personal/components/update-avatar.vue
  23. 140 0
      apps/web-baicai/src/views/system/personal/components/update-info.vue
  24. 131 0
      apps/web-baicai/src/views/system/personal/components/update-password.vue
  25. 47 0
      apps/web-baicai/src/views/system/personal/index.vue
  26. 15 2
      apps/web-baicai/src/views/system/user/components/edit.vue
  27. 1 1
      apps/web-baicai/src/views/system/user/data.config.ts
  28. 2 2
      packages/effects/request/src/request-client/preset-interceptors.ts

+ 21 - 1
apps/web-baicai/src/api/model/index.ts

@@ -91,7 +91,7 @@ export const connectTypeOptions: BasicOptionResult[] = [
   { label: '全联接', value: 3 },
 ];
 
-export const ConditionalTypeOptions: BasicOptionResult[] = [
+export const conditionalTypeOptions: BasicOptionResult[] = [
   { label: '等于', value: 0 },
   { label: '包含', value: 1 },
   { label: '大于', value: 2 },
@@ -110,3 +110,23 @@ export const ConditionalTypeOptions: BasicOptionResult[] = [
   // { 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' },
+];

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

@@ -100,11 +100,10 @@ function createRequestClient(baseURL: string) {
   client.addResponseInterceptor(
     errorMessageResponseInterceptor((msg: string, _error) => {
       // 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
-      if (_error?.data?.code === 0) {
-        message.error(msg);
-      } else {
-        message.error(_error?.data?.msg);
-      }
+      const responseData = _error?.data ?? {};
+      const errorMessage = responseData?.error ?? responseData?.message ?? '';
+      // 如果没有错误信息,则会根据状态码进行提示
+      message.error(errorMessage || msg);
     }),
   );
 

+ 61 - 0
apps/web-baicai/src/api/system/database.ts

@@ -1,3 +1,5 @@
+import type { BasicFetchResult, BasicPageParams } from '../model';
+
 import { requestClient } from '#/api/request';
 
 export namespace DatabaseApi {
@@ -14,6 +16,43 @@ export namespace DatabaseApi {
     dataType: string;
     netType: string;
   }
+
+  export interface PageRecordItem {
+    description: string;
+    name: string;
+    configId: string;
+    id: number;
+  }
+
+  export interface BasicColumnRecord {
+    columnName: string;
+    dataType: string;
+    description: string;
+    isPrimarykey: boolean;
+    isNullable: boolean;
+    decimalDigits: number;
+    isIdentity: boolean;
+    length: number;
+  }
+
+  export interface BasicRecordItem {
+    name: string;
+    description: string;
+    configId: string;
+    columns: BasicColumnRecord[];
+  }
+
+  export interface RecordItem extends BasicRecordItem {
+    id: number;
+  }
+
+  export interface PageParams extends BasicPageParams {
+    name?: string;
+    description?: string;
+  }
+
+  export type PageResult = BasicFetchResult<PageRecordItem>;
+
   export const getList = (configid: string) =>
     requestClient.get<DbTableItem[]>(`/database/table/list/${configid}`);
 
@@ -21,4 +60,26 @@ export namespace DatabaseApi {
     requestClient.get<DbColumnItem[]>(
       `/database/column/list/${tablename}/${configid}`,
     );
+
+  export const getPage = (params: PageParams) =>
+    requestClient.get<PageResult>('/database/table/page', { params });
+
+  export const getDetail = (id: number) =>
+    requestClient.get<RecordItem>('/database/table/entity', {
+      params: { id },
+    });
+
+  export const addDetail = (data: BasicRecordItem) =>
+    requestClient.post('/database/table', data);
+
+  export const editDetail = (data: RecordItem) =>
+    requestClient.put('/database/table', data);
+
+  export const deleteDetail = (id: number) =>
+    requestClient.delete('/database/table', { data: { id } });
+
+  export const getCreate = (id: number) =>
+    requestClient.get<RecordItem>('/database/table/create', {
+      params: { id },
+    });
 }

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

@@ -1,3 +1,5 @@
+import type { BasicTreeOptionResult } from '../model';
+
 import { requestClient } from '#/api/request';
 
 export namespace MenuApi {
@@ -14,6 +16,7 @@ export namespace MenuApi {
     component: string;
     sort: number;
     status: number;
+    meta: any;
   }
 
   export interface RecordItem extends BasicRecordItem {
@@ -23,6 +26,9 @@ export namespace MenuApi {
   export const getList = (params: PageParams) =>
     requestClient.get<RecordItem[]>('/menu/list', { params });
 
+  export const getTree = () =>
+    requestClient.get<BasicTreeOptionResult[]>('/menu/tree');
+
   export const getDetail = (id: number) =>
     requestClient.get<RecordItem>('/menu/entity', {
       params: { id },

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

@@ -54,4 +54,7 @@ export namespace UserApi {
     requestClient.post('/user/reset-password', ids);
   export const updateGrantRole = (data: RelationRequest) =>
     requestClient.post('/user/grant', data);
+
+  export const updatePassword = (data: any) =>
+    requestClient.put('/user/password', data);
 }

+ 1 - 1
apps/web-baicai/src/components/form/components/api-popup/api-popup-modal-ignore.vue

@@ -4,7 +4,7 @@ import { reactive, ref } from 'vue';
 import { useVbenModal } from '@vben/common-ui';
 import { isEmpty } from '@vben/utils';
 
-import { message } from 'ant-design-vue';
+import { Button, message } from 'ant-design-vue';
 
 import TablePage from '#/views/system/design/table/components/page.vue';
 

+ 37 - 4
apps/web-baicai/src/components/icon/icon.vue

@@ -1,19 +1,52 @@
 <script setup lang="ts">
-import { computed } from 'vue';
+import { computed, h } from 'vue';
 
-import { createIconifyIcon } from '@vben/icons';
+import { IconifyIcon as VbenIcon } from '@vben/icons';
 
 const props = defineProps({
   icon: {
     type: String,
     default: '',
   },
+  size: {
+    type: [String, Number],
+    default: '16px',
+  },
 });
 const iconComp = computed(() => {
-  return createIconifyIcon(props.icon);
+  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" />
+  <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>

+ 5 - 1
apps/web-baicai/src/components/table-action/src/table-action.vue

@@ -153,7 +153,11 @@ const handleMenuClick = (e: any) => {
       </slot>
       <template #overlay>
         <Menu @click="handleMenuClick">
-          <MenuItem v-for="(action, index) in getDropdownList" :key="index">
+          <MenuItem
+            v-for="(action, index) in getDropdownList"
+            :key="index"
+            :disabled="action.disabled"
+          >
             <template v-if="action.popConfirm">
               <Popconfirm v-bind="getPopConfirmProps(action.popConfirm)">
                 <template v-if="action.popConfirm.icon" #icon>

+ 11 - 0
apps/web-baicai/src/layouts/basic.vue

@@ -2,6 +2,7 @@
 import type { NotificationItem } from '@vben/layouts';
 
 import { computed, 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';
@@ -55,6 +56,7 @@ const notifications = ref<NotificationItem[]>([
 const userStore = useUserStore();
 const authStore = useAuthStore();
 const accessStore = useAccessStore();
+const router = useRouter();
 const { destroyWatermark, updateWatermark } = useWatermark();
 const showDot = computed(() =>
   notifications.value.some((item) => !item.isRead),
@@ -70,6 +72,15 @@ const menus = computed(() => [
     icon: BookOpenText,
     text: $t('ui.widgets.document'),
   },
+  {
+    handler: () => {
+      router.push({
+        name: 'personal',
+      });
+    },
+    icon: 'fa-solid:user-tag',
+    text: '个人中心',
+  },
   {
     handler: () => {
       openWindow(VBEN_GITHUB_URL, {

+ 22 - 1
apps/web-baicai/src/router/routes/core.ts

@@ -2,7 +2,7 @@ import type { RouteRecordRaw } from 'vue-router';
 
 import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
 
-import { AuthPageLayout } from '#/layouts';
+import { AuthPageLayout, BasicLayout } from '#/layouts';
 import { $t } from '#/locales';
 import Login from '#/views/_core/authentication/login.vue';
 
@@ -83,6 +83,27 @@ const coreRoutes: RouteRecordRaw[] = [
       },
     ],
   },
+  {
+    component: BasicLayout,
+    meta: {
+      icon: 'ant-design:user-outlined',
+      title: '个人中心',
+      hideInMenu: true,
+    },
+    name: 'extend',
+    path: '/extend',
+    children: [
+      {
+        meta: {
+          icon: 'fa-solid:user-cog',
+          title: '个人中心',
+        },
+        name: 'personal',
+        path: 'personal',
+        component: () => import('#/views/system/personal/index.vue'),
+      },
+    ],
+  },
 ];
 
 export { coreRoutes, fallbackNotFoundRoute };

+ 181 - 0
apps/web-baicai/src/views/system/design/database/components/edit.vue

@@ -0,0 +1,181 @@
+<script lang="ts" setup>
+import { ref, unref } from 'vue';
+
+import { useAccess } from '@vben/access';
+import { useVbenModal } from '@vben/common-ui';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { DatabaseApi } from '#/api';
+import { TableAction } from '#/components/table-action';
+
+import { formOptions, gridColumnOptions } from '../data.config';
+import FormEdit from './editColumn.vue';
+
+defineOptions({
+  name: 'DatabaseEdit',
+});
+const emit = defineEmits(['success']);
+
+const { hasAccessByCodes } = useAccess();
+const modelRef = ref<Record<string, any>>({});
+const isUpdate = ref(true);
+
+const [Form, { validate, setValues, getValues, updateSchema }] = useVbenForm({
+  showDefaultActions: false,
+  ...formOptions,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridOptions: gridColumnOptions,
+});
+
+const [FormEditModal, formEditApi] = useVbenModal({
+  connectedComponent: FormEdit,
+});
+
+const [Modal, { close, setState, getData }] = useVbenModal({
+  // fullscreenButton: true,
+  fullscreen: true,
+  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);
+
+        const data = gridApi.grid.getTableData();
+        postParams.columns = data.fullData;
+
+        await (unref(isUpdate)
+          ? DatabaseApi.editDetail(postParams as DatabaseApi.RecordItem)
+          : DatabaseApi.addDetail(postParams as DatabaseApi.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 DatabaseApi.getDetail(data.baseData.id);
+        modelRef.value = { ...entity };
+
+        setValues(entity);
+
+        gridApi.grid.reloadData(entity.columns);
+
+        updateSchema([
+          {
+            fieldName: 'name',
+            componentProps: { disabled: true },
+          },
+        ]);
+      } else {
+        updateSchema([
+          {
+            fieldName: 'name',
+            componentProps: { disabled: false },
+          },
+        ]);
+      }
+      setState({ loading: false });
+    }
+  },
+  title: '新增表',
+});
+
+const handleDelete = (record: any) => {
+  gridApi.grid.remove(record);
+};
+
+const handleEdit = (record: any, isUpdate: boolean) => {
+  formEditApi.setData({
+    isUpdate,
+    baseData: record,
+  });
+  if (isUpdate) {
+    gridApi.grid.setEditRow(record);
+  }
+  formEditApi.open();
+};
+
+const handelSuccess = (record: any) => {
+  if (record.isUpdate) {
+    // const row = gridApi.grid.getEditRecord();
+    // console.log('edit row', row);
+    // if (gridApi.grid.isUpdateByRow(record.sourceData)) {
+    //   gridApi.grid.reloadRow(record.sourceData, record.data);
+    // } else {
+    //   console.log('not found row');
+    // }
+  }
+  // else {
+  //   gridApi.grid.insert(record.data);
+  // }
+  gridApi.grid.insert(record.data);
+};
+</script>
+<template>
+  <Modal class="h-[800px] w-[1000px]">
+    <FormEditModal :close-on-click-modal="false" @success="handelSuccess" />
+    <div class="h-full">
+      <div class="h-[168px]">
+        <Form />
+      </div>
+      <div class="h-full" style="height: calc(100% - 168px)">
+        <Grid>
+          <template #toolbar-tools>
+            <Button
+              class="mr-2"
+              type="primary"
+              v-access:code="'table:add'"
+              @click="() => handleEdit({}, false)"
+            >
+              新增列
+            </Button>
+          </template>
+
+          <template #action="{ row }">
+            <TableAction
+              :actions="[
+                {
+                  label: '编辑',
+                  type: 'text',
+                  disabled: !hasAccessByCodes(['table:edit']),
+                  onClick: handleEdit.bind(null, row, true),
+                },
+                {
+                  label: '删除',
+                  type: 'text',
+                  disabled: !hasAccessByCodes(['table:delete']),
+                  onClick: handleDelete.bind(null, row),
+                },
+              ]"
+            />
+          </template>
+        </Grid>
+      </div>
+    </div>
+  </Modal>
+</template>

+ 74 - 0
apps/web-baicai/src/views/system/design/database/components/editColumn.vue

@@ -0,0 +1,74 @@
+<script lang="ts" setup>
+import { onMounted, ref, unref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+
+import { formColumnOptions } from '../data.config';
+
+defineOptions({
+  name: 'TableColumnEdit',
+});
+const emit = defineEmits(['success']);
+const modelRef = ref<Record<string, any>>({});
+const isUpdate = ref(true);
+
+const [Form, { validate, setValues, getValues }] = useVbenForm({
+  showDefaultActions: false,
+  ...formColumnOptions,
+});
+
+const [Modal, { close, setState, getData }] = useVbenModal({
+  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);
+
+        close();
+        emit('success', {
+          isUpdate: unref(isUpdate),
+          data: postParams,
+        });
+      }
+    } 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)) {
+        setValues({ ...data.baseData });
+      }
+      setState({ loading: false });
+    }
+  },
+  title: '新增列',
+});
+
+onMounted(async () => {});
+</script>
+<template>
+  <Modal class="w-[1000px]">
+    <Form />
+  </Modal>
+</template>

+ 258 - 0
apps/web-baicai/src/views/system/design/database/data.config.ts

@@ -0,0 +1,258 @@
+import type { VbenFormProps } from '#/adapter/form';
+import type { VxeGridProps } from '#/adapter/vxe-table';
+
+import { DatabaseApi, TenantApi } from '#/api';
+import { dataTypeOptions } from '#/api/model';
+
+export const searchFormOptions: VbenFormProps = {
+  showCollapseButton: false,
+  schema: [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: '表名',
+    },
+    {
+      component: 'Input',
+      fieldName: 'description',
+      label: '说明',
+    },
+  ],
+};
+
+export const gridOptions: VxeGridProps<DatabaseApi.RecordItem> = {
+  toolbarConfig: {
+    refresh: true,
+    print: false,
+    export: false,
+    zoom: true,
+    custom: true,
+  },
+  columns: [
+    { title: '序号', type: 'seq', width: 50 },
+    {
+      align: 'left',
+      field: 'name',
+      title: '表名',
+      width: 200,
+    },
+    { align: 'left', field: 'description', title: '备注' },
+    {
+      field: 'action',
+      fixed: 'right',
+      slots: { default: 'action' },
+      title: '操作',
+      width: 170,
+    },
+  ],
+  height: 'auto',
+  keepSource: true,
+  proxyConfig: {
+    ajax: {
+      query: async ({ page }, formValues) => {
+        return await DatabaseApi.getPage({
+          pageIndex: page.currentPage,
+          pageSize: page.pageSize,
+          ...formValues,
+        });
+      },
+    },
+  },
+};
+
+export const formOptions: VbenFormProps = {
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+  },
+  schema: [
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        placeholder: '请输入',
+        api: {
+          url: TenantApi.getOptions,
+        },
+        showSearch: true,
+        numberToString: true,
+      },
+      fieldName: 'configId',
+      label: '数据库',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入表名',
+      },
+      fieldName: 'name',
+      label: '表名',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入说明',
+      },
+      fieldName: 'description',
+      label: '说明',
+      rules: 'required',
+    },
+  ],
+  wrapperClass: 'grid-cols-1',
+};
+
+export const gridColumnOptions: VxeGridProps<DatabaseApi.BasicColumnRecord> = {
+  rowConfig: {
+    isCurrent: true,
+    isHover: true,
+    keyField: 'columnName',
+    drag: true,
+    useKey: true,
+  },
+  columnConfig: {
+    useKey: true,
+  },
+  columns: [
+    { title: '序号', type: 'seq', width: 50 },
+    {
+      align: 'left',
+      field: 'columnName',
+      title: '列名',
+      width: 200,
+      dragSort: true,
+    },
+    { align: 'left', field: 'dataType', title: '类型', width: 100 },
+    { align: 'left', field: 'description', title: '说明' },
+    {
+      field: 'action',
+      fixed: 'right',
+      slots: { default: 'action' },
+      title: '操作',
+      width: 110,
+    },
+  ],
+  editConfig: {
+    mode: 'row',
+    trigger: 'click',
+    // showStatus: true,
+  },
+  height: 'auto',
+  keepSource: true,
+  pagerConfig: {
+    enabled: false,
+  },
+  dragConfig: {
+    rowIcon: 'vxe-icon-sort',
+  },
+};
+
+export const formColumnOptions: VbenFormProps = {
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+    },
+  },
+  schema: [
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入字段名',
+      },
+      fieldName: 'columnName',
+      label: '字段名',
+      rules: 'required',
+    },
+    {
+      component: 'Select',
+      componentProps: {
+        placeholder: '请输入数据类型',
+        options: dataTypeOptions,
+        showSearch: true,
+      },
+      fieldName: 'dataType',
+      label: '数据类型',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入说明',
+      },
+      fieldName: 'description',
+      label: '说明',
+      rules: 'required',
+    },
+    {
+      component: 'Switch',
+      defaultValue: false,
+      componentProps: {
+        placeholder: '请输入主键',
+        class: 'w-auto',
+      },
+      fieldName: 'isPrimarykey',
+      label: '主键',
+    },
+    {
+      component: 'Switch',
+      defaultValue: false,
+      componentProps: {
+        placeholder: '请输入自动增加',
+        class: 'w-auto',
+      },
+      fieldName: 'isIdentity',
+      label: '自动增加',
+      dependencies: {
+        show(values) {
+          return values.isPrimarykey === true;
+        },
+        triggerFields: ['isPrimarykey'],
+      },
+    },
+    {
+      component: 'Switch',
+      defaultValue: true,
+      componentProps: {
+        placeholder: '请输入允许为空',
+        class: 'w-auto',
+      },
+      fieldName: 'isNullable',
+      label: '允许为空',
+      rules: 'required',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入长度',
+      },
+      fieldName: 'length',
+      label: '长度',
+      rules: 'required',
+      dependencies: {
+        show(values) {
+          return ['char', 'decimal', 'nchar', 'nvarchar', 'varchar'].includes(
+            values.dataType,
+          );
+        },
+        triggerFields: ['dataType'],
+      },
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入小数位数',
+      },
+      fieldName: 'decimalDigits',
+      label: '小数位数',
+      rules: 'required',
+      dependencies: {
+        show(values) {
+          return ['decimal'].includes(values.dataType);
+        },
+        triggerFields: ['dataType'],
+      },
+    },
+  ],
+  wrapperClass: 'grid-cols-1',
+};

+ 109 - 0
apps/web-baicai/src/views/system/design/database/index.vue

@@ -0,0 +1,109 @@
+<script lang="ts" setup>
+import { useAccess } from '@vben/access';
+import { Page, useVbenModal } from '@vben/common-ui';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { DatabaseApi } 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] = useVbenModal({
+  connectedComponent: FormEdit,
+});
+
+const handleDelete = (id: number) => {
+  Modal.confirm({
+    iconType: 'info',
+    title: '删除提示',
+    content: `确定要删除选择的记录吗?`,
+    cancelText: `关闭`,
+    onOk: async () => {
+      await DatabaseApi.deleteDetail(id);
+      message.success('数据删除成功');
+      reload();
+    },
+  });
+};
+
+const handelCreate = (id: number) => {
+  Modal.confirm({
+    iconType: 'info',
+    title: '提示',
+    content: `创建表如果表存在会先删除原表,确定要创建表吗?`,
+    cancelText: `关闭`,
+    onOk: async () => {
+      await DatabaseApi.getCreate(id);
+      message.success('创建表成功');
+      reload();
+    },
+  });
+};
+
+const handleEdit = (record: any, isUpdate: boolean) => {
+  formEditApi.setData({
+    isUpdate,
+    baseData: { id: record.id },
+  });
+
+  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="'table:add'"
+          @click="() => handleEdit({}, false)"
+        >
+          新增表
+        </Button>
+      </template>
+      <template #action="{ row }">
+        <TableAction
+          :actions="[
+            {
+              label: '编辑',
+              type: 'text',
+              disabled: !hasAccessByCodes(['table:edit']),
+              onClick: handleEdit.bind(null, row, true),
+            },
+            {
+              label: '创建表',
+              type: 'text',
+              disabled: !hasAccessByCodes(['table:create']),
+              onClick: handelCreate.bind(null, row.id),
+            },
+          ]"
+          :drop-down-actions="[
+            {
+              label: '删除',
+              type: 'link',
+              disabled: !hasAccessByCodes(['table:delete']),
+              onClick: handleDelete.bind(null, row.id),
+            },
+          ]"
+        />
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 22 - 5
apps/web-baicai/src/views/system/design/table/components/page.vue

@@ -1,6 +1,7 @@
 <script lang="ts" setup>
-import { type PropType, ref, watch } from 'vue';
+import { type PropType, reactive, ref, watch } from 'vue';
 
+import { Fallback } from '@vben/common-ui';
 import { useVbenVxeGrid } from '@vben/plugins/vxe-table';
 
 import { TableApi } from '#/api';
@@ -25,6 +26,9 @@ const props = defineProps({
 const emit = defineEmits(['success']);
 
 const modelRef = ref<Record<string, any>>({});
+const state = reactive({
+  error: false,
+});
 
 const [Grid, gridApi] = useVbenVxeGrid({
   gridOptions: {
@@ -156,7 +160,13 @@ watch(
   () => props.tableQueryCode,
   async (value) => {
     if (value && value.length > 0) {
-      await loadPage(value);
+      try {
+        await loadPage(value);
+      } catch {
+        state.error = true;
+      }
+    } else {
+      state.error = true;
     }
   },
   { immediate: true },
@@ -172,8 +182,15 @@ defineExpose({ getSelectRow });
 </script>
 <template>
   <div class="h-full">
-    <Grid>
-      <template #toolbar-tools></template>
-    </Grid>
+    <template v-if="!state.error">
+      <Grid>
+        <template #toolbar-tools></template>
+      </Grid>
+    </template>
+    <template v-else>
+      <Fallback status="404">
+        <template #action></template>
+      </Fallback>
+    </template>
   </div>
 </template>

+ 27 - 0
apps/web-baicai/src/views/system/design/table/index.vue

@@ -7,6 +7,7 @@ import { Button, message, Modal } from 'ant-design-vue';
 import { useVbenVxeGrid } from '#/adapter/vxe-table';
 import { TableApi } from '#/api';
 import { TableAction } from '#/components/table-action';
+import FormMenuEdit from '#/views/system/menu/components/editMenu.vue';
 
 import FormEdit from './components/edit.vue';
 import FormPreview from './components/preview.vue';
@@ -23,6 +24,10 @@ const [FormEditModal, formEditApi] = useVbenModal({
   connectedComponent: FormEdit,
 });
 
+const [FormMenuEditModal, formMenuEditApi] = useVbenModal({
+  connectedComponent: FormMenuEdit,
+});
+
 const [FormPreviewModal, formPreviewApi] = useVbenModal({
   connectedComponent: FormPreview,
 });
@@ -61,11 +66,24 @@ const handlePreview = (record: any) => {
 const handelSuccess = () => {
   reload();
 };
+
+const handleCreateMenu = (record: any) => {
+  formMenuEditApi.setData({
+    isUpdate: record.menuId > 0,
+    baseData: {
+      id: record.menuId,
+      component: `/system/design/template/index`,
+      meta: { query: { code: record.code } },
+    },
+  });
+  formMenuEditApi.open();
+};
 </script>
 
 <template>
   <Page auto-content-height>
     <FormEditModal :close-on-click-modal="false" @success="handelSuccess" />
+    <FormMenuEditModal :close-on-click-modal="false" @success="handelSuccess" />
     <FormPreviewModal :close-on-click-modal="false" />
     <Grid>
       <template #toolbar-tools>
@@ -95,6 +113,15 @@ const handelSuccess = () => {
             },
           ]"
           :drop-down-actions="[
+            {
+              label: row.menuId > 0 ? '修改菜单' : '配置菜单',
+              type: 'text',
+              disabled:
+                row.tableType !== 'page_list'
+                  ? true
+                  : !hasAccessByCodes(['page:edit']),
+              onClick: handleCreateMenu.bind(null, row),
+            },
             {
               label: '删除',
               type: 'link',

+ 38 - 0
apps/web-baicai/src/views/system/design/template/index.vue

@@ -0,0 +1,38 @@
+<script lang="ts" setup>
+import { reactive, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { Page, VbenLoading } from '@vben/common-ui';
+
+import TablePage from '#/views/system/design/table/components/page.vue';
+
+const route = useRoute();
+
+const state = reactive<{
+  mode: 'page' | 'popup' | 'view';
+  multiple: boolean;
+  tableQueryCode: string;
+}>({
+  tableQueryCode: (route.query?.code || '') as string,
+  multiple: false,
+  mode: 'page',
+});
+
+const tablePageRef = ref();
+
+const showLoading = ref(true);
+const handelLoadData = () => {
+  showLoading.value = false;
+};
+</script>
+
+<template>
+  <Page auto-content-height>
+    <VbenLoading
+      v-if="showLoading"
+      class="size-full h-auto min-h-full"
+      spinning
+    />
+    <TablePage ref="tablePageRef" v-bind="state" @success="handelLoadData" />
+  </Page>
+</template>

+ 20 - 3
apps/web-baicai/src/views/system/menu/components/edit.vue

@@ -1,5 +1,5 @@
 <script lang="ts" setup>
-import { onMounted, ref, unref } from 'vue';
+import { onMounted, reactive, ref, unref } from 'vue';
 
 import { useVbenDrawer } from '@vben/common-ui';
 
@@ -17,6 +17,10 @@ const emit = defineEmits(['success']);
 const modelRef = ref<Record<string, any>>({});
 const isUpdate = ref(true);
 
+const state = reactive({
+  metaFields: ['title', 'icon', 'keepAlive', 'hideInTab', 'iframeSrc', 'link'],
+});
+
 const [Form, { validate, setValues, getValues }] = useVbenForm({
   showDefaultActions: false,
   ...formOptions,
@@ -33,7 +37,13 @@ const [Drawer, { close, setState, getData }] = useVbenDrawer({
         const values = await getValues();
         setState({ confirmLoading: true });
         const postParams = unref(modelRef);
-        Object.assign(postParams, values);
+
+        const meta: any = {};
+        state.metaFields.forEach((field) => {
+          meta[field] = values[field];
+        });
+        Object.assign(postParams, values, { meta });
+
         await (unref(isUpdate)
           ? MenuApi.editDetail(postParams as MenuApi.RecordItem)
           : MenuApi.addDetail(postParams as MenuApi.BasicRecordItem));
@@ -59,7 +69,14 @@ const [Drawer, { close, setState, getData }] = useVbenDrawer({
       if (unref(isUpdate)) {
         const entity = await MenuApi.getDetail(data.baseData.id);
         modelRef.value = { ...entity };
-        setValues(entity);
+
+        const formatData: any = { ...entity };
+
+        state.metaFields.forEach((field) => {
+          formatData[field] = entity.meta[field];
+        });
+
+        setValues(formatData);
       }
       setState({ loading: false });
     }

+ 97 - 0
apps/web-baicai/src/views/system/menu/components/editMenu.vue

@@ -0,0 +1,97 @@
+<script lang="ts" setup>
+import { onMounted, reactive, ref, unref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { MenuApi } from '#/api';
+
+import { formMenuOptions } from '../data.config';
+
+defineOptions({
+  name: 'Menu1Edit',
+});
+const emit = defineEmits(['success']);
+const modelRef = ref<Record<string, any>>({});
+const isUpdate = ref(true);
+
+const state = reactive({
+  metaFields: ['title', 'icon', 'keepAlive', 'hideInTab', 'iframeSrc', 'link'],
+});
+
+const [Form, { validate, setValues, getValues }] = useVbenForm({
+  showDefaultActions: false,
+  ...formMenuOptions,
+});
+
+const [Modal, { close, setState, getData }] = useVbenModal({
+  fullscreen: 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);
+
+        const meta: any = { query: { ...postParams.meta.query } };
+        state.metaFields.forEach((field) => {
+          meta[field] = values[field];
+        });
+
+        Object.assign(postParams, values, { type: 1, isMake: 1 }, { meta });
+
+        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 };
+        const formatData: any = { ...entity };
+
+        state.metaFields.forEach((field) => {
+          formatData[field] = entity.meta[field];
+        });
+
+        setValues(formatData);
+      } else {
+        setValues({ component: data.baseData.component });
+      }
+      setState({ loading: false });
+    }
+  },
+  title: '新增菜单',
+});
+
+onMounted(async () => {});
+</script>
+<template>
+  <Modal class="w-[1000px]">
+    <Form />
+  </Modal>
+</template>

+ 3 - 3
apps/web-baicai/src/views/system/menu/components/grant.vue

@@ -131,10 +131,10 @@ const handleExpandAndCollapse = () => {
       @check="handleCheck"
       @expand="handleExpand"
     >
-      <template #title="{ icon, title }">
+      <template #title="{ meta }">
         <div class="flex items-center">
-          <Icon v-if="icon" :icon="icon" class="mr-1" />
-          <span>{{ title }}</span>
+          <Icon v-if="meta.icon" :icon="meta.icon" class="mr-1" />
+          <span>{{ meta.title }}</span>
         </div>
       </template>
     </Tree>

+ 220 - 8
apps/web-baicai/src/views/system/menu/data.config.ts

@@ -40,14 +40,14 @@ export const gridOptions: VxeGridProps<MenuApi.RecordItem> = {
     { title: '序号', type: 'seq', width: 50 },
     {
       align: 'left',
-      field: 'title',
+      field: 'meta.title',
       title: '菜单名称',
       width: 200,
       treeNode: true,
       showOverflow: true,
       slots: {
-        default: ({ row }) => {
-          return row.icon
+        default: ({ row }: any) => {
+          return row.meta.icon
             ? h(
                 'span',
                 {
@@ -58,7 +58,7 @@ export const gridOptions: VxeGridProps<MenuApi.RecordItem> = {
                 },
                 [
                   h(Icon, {
-                    icon: row.icon,
+                    icon: row.meta.icon,
                   }),
                   h(
                     'span',
@@ -67,11 +67,11 @@ export const gridOptions: VxeGridProps<MenuApi.RecordItem> = {
                         paddingLeft: '6px',
                       },
                     },
-                    row.title,
+                    row.meta.title,
                   ),
                 ],
               )
-            : h('span', {}, row.title);
+            : h('span', {}, row.meta.title);
         },
       },
     },
@@ -150,6 +150,7 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'name',
       label: '路由名称',
+      help: 'route.name',
       rules: 'required',
       dependencies: {
         show(values) {
@@ -165,6 +166,7 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'path',
       label: '路由地址',
+      help: 'route.path',
       rules: 'required',
       dependencies: {
         triggerFields: ['type'],
@@ -186,6 +188,7 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'component',
       label: '组件路径',
+      help: 'route.component',
       rules: 'required',
     },
     {
@@ -201,6 +204,7 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'redirect',
       label: '重定向',
+      help: 'route.redirect',
     },
     {
       component: 'Input',
@@ -208,6 +212,7 @@ export const formOptions: VbenFormProps = {
         placeholder: '请输入菜单名称',
       },
       fieldName: 'title',
+      help: 'meta.title',
       label: '菜单名称',
       rules: 'required',
     },
@@ -218,6 +223,7 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'icon',
       label: '图标',
+      help: 'meta.icon',
       rules: 'required',
       dependencies: {
         show(values) {
@@ -228,7 +234,7 @@ export const formOptions: VbenFormProps = {
     },
     {
       component: 'RadioGroup',
-      defaultValue: 1,
+      defaultValue: true,
       componentProps: {
         placeholder: '请输入',
         options: boolOptions,
@@ -243,10 +249,11 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'keepAlive',
       label: '缓存',
+      help: 'meta.keepAlive',
     },
     {
       component: 'RadioGroup',
-      defaultValue: 0,
+      defaultValue: false,
       componentProps: {
         placeholder: '请输入',
         options: boolOptions,
@@ -261,9 +268,11 @@ export const formOptions: VbenFormProps = {
       },
       fieldName: 'hideInTab',
       label: '隐藏',
+      help: 'meta.keepAlive',
     },
     {
       component: 'ApiSelect',
+      defaultValue: 0,
       componentProps: {
         placeholder: '请输入',
         api: {
@@ -281,6 +290,38 @@ export const formOptions: VbenFormProps = {
       label: '路由类型',
       rules: 'required',
     },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入内嵌页面',
+      },
+      dependencies: {
+        triggerFields: ['pathType'],
+        show(values) {
+          return [1].includes(values.pathType);
+        },
+      },
+      fieldName: 'iframeSrc',
+      help: 'meta.iframeSrc',
+      label: '内嵌页面',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入外链地址',
+      },
+      dependencies: {
+        triggerFields: ['pathType'],
+        show(values) {
+          return [2].includes(values.pathType);
+        },
+      },
+      fieldName: 'link',
+      help: 'meta.link',
+      label: '外链地址',
+      rules: 'required',
+    },
     {
       component: 'InputNumber',
       componentProps: {
@@ -322,3 +363,174 @@ export const formOptions: VbenFormProps = {
   showDefaultActions: false,
   wrapperClass: 'grid-cols-1',
 };
+
+export const formMenuOptions: VbenFormProps = {
+  commonConfig: {
+    componentProps: {
+      class: 'w-full',
+      labelWidth: 110,
+    },
+  },
+  schema: [
+    {
+      component: 'ApiTreeSelect',
+      componentProps: {
+        placeholder: '请输入上级菜单',
+        api: {
+          url: MenuApi.getTree,
+        },
+      },
+      fieldName: 'pid',
+      label: '上级菜单',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入路由名称',
+      },
+      fieldName: 'name',
+      label: '路由名称',
+      help: 'route.name',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入路由地址',
+      },
+      fieldName: 'path',
+      label: '路由地址',
+      help: 'route.path',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入组件路径',
+        disabled: true,
+      },
+      fieldName: 'component',
+      label: '组件路径',
+      help: 'route.component',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入菜单名称',
+      },
+      fieldName: 'title',
+      label: '菜单名称',
+      help: 'meta.title',
+      rules: 'required',
+    },
+    {
+      component: 'IconPicker',
+      componentProps: {
+        placeholder: '请输入图标',
+      },
+      fieldName: 'icon',
+      label: '图标',
+      help: 'meta.icon',
+      rules: 'required',
+    },
+    {
+      component: 'RadioGroup',
+      defaultValue: true,
+      componentProps: {
+        placeholder: '请输入',
+        options: boolOptions,
+        optionType: 'button',
+        buttonStyle: 'solid',
+      },
+      fieldName: 'keepAlive',
+      label: '缓存',
+      help: 'meta.keepAlive',
+    },
+    {
+      component: 'RadioGroup',
+      defaultValue: false,
+      componentProps: {
+        placeholder: '请输入',
+        options: boolOptions,
+        optionType: 'button',
+        buttonStyle: 'solid',
+      },
+      fieldName: 'hideInTab',
+      help: 'meta.hideInTab',
+      label: '隐藏',
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        placeholder: '请输入',
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.PathType,
+        },
+      },
+      defaultValue: 0,
+      fieldName: 'pathType',
+      label: '路由类型',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入内嵌页面',
+      },
+      dependencies: {
+        triggerFields: ['pathType'],
+        show(values) {
+          return [1].includes(values.pathType);
+        },
+      },
+      fieldName: 'iframeSrc',
+      help: 'meta.iframeSrc',
+      label: '内嵌页面',
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入外链地址',
+      },
+      dependencies: {
+        triggerFields: ['pathType'],
+        show(values) {
+          return [2].includes(values.pathType);
+        },
+      },
+      fieldName: 'link',
+      help: 'meta.link',
+      label: '外链地址',
+      rules: 'required',
+    },
+    {
+      component: 'InputNumber',
+      componentProps: {
+        placeholder: '请输入',
+      },
+      fieldName: 'sort',
+      label: '排序',
+      rules: 'required',
+    },
+    {
+      component: 'ApiRadio',
+      defaultValue: 1,
+      componentProps: {
+        placeholder: '请输入',
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.Status,
+        },
+        isBtn: true,
+      },
+      fieldName: 'status',
+      label: '状态',
+    },
+  ],
+  showDefaultActions: false,
+  wrapperClass: 'grid-cols-2',
+};

+ 59 - 0
apps/web-baicai/src/views/system/personal/components/update-avatar.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+
+import { useAccessStore, useUserStore } from '@vben/stores';
+
+import {
+  Avatar,
+  message,
+  Upload,
+  type UploadChangeParam,
+} from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+
+defineProps({
+  avatar: {
+    type: String,
+    default: '',
+  },
+});
+const accessStore = useAccessStore();
+const userStore = useUserStore();
+
+const getAction = computed(() => {
+  return `${import.meta.env.VITE_GLOB_API_URL}/user/avatar`;
+});
+const headers = {
+  Authorization: `Bearer ${accessStore.accessToken}`,
+};
+const handleChange = (info: UploadChangeParam) => {
+  if (info.file.status === 'done') {
+    userStore.setUserInfo({
+      ...userStore.userInfo,
+      avatar: info.file.response.data.url,
+    } as any);
+  } else if (info.file.status === 'error') {
+    message.error(`${info.file.name} file upload failed.`);
+  }
+};
+</script>
+<template>
+  <Upload
+    :action="getAction"
+    :headers="headers"
+    :max-count="1"
+    :show-upload-list="false"
+    accept="image/*"
+    class="relative"
+    @change="handleChange"
+  >
+    <Avatar :size="120" :src="avatar" />
+    <div
+      class="absolute left-[50%-120px] top-[0px] flex h-[120px] w-[120px] cursor-pointer items-center justify-center rounded-full bg-black opacity-0 hover:opacity-30"
+    >
+      <Icon :size="48" icon="ant-design:cloud-upload-outlined" />
+    </div>
+  </Upload>
+</template>
+<style lang="less" scoped></style>

+ 140 - 0
apps/web-baicai/src/views/system/personal/components/update-info.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+import { h, onMounted, ref, unref } from 'vue';
+
+import { useUserStore } from '@vben/stores';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { EnumApi, UserApi } from '#/api';
+
+const userStore = useUserStore();
+const loading = ref<boolean>(false);
+const modelRef = ref<Record<string, any>>({});
+
+const [BasicForm, formApi] = useVbenForm({
+  wrapperClass: 'grid-cols-1 w-[50%]', // 24栅格,
+  commonConfig: {
+    formItemClass: 'col-span-1',
+    labelWidth: 60,
+  },
+  showDefaultActions: false,
+  submitButtonOptions: {
+    content: '更新信息',
+  },
+  resetButtonOptions: {
+    show: false,
+  },
+  schema: [
+    {
+      fieldName: 'realName',
+      label: '姓名',
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入姓名',
+      },
+      formItemClass: 'col-span-1',
+      rules: 'required',
+    },
+    {
+      component: 'ApiRadio',
+      componentProps: {
+        placeholder: '请输入',
+        api: {
+          type: 'enum',
+          params: EnumApi.EnumType.Gender,
+        },
+      },
+      fieldName: 'sex',
+      label: '性别',
+      rules: 'required',
+    },
+    {
+      fieldName: 'nickName',
+      label: '昵称',
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入昵称',
+      },
+      formItemClass: 'col-span-1',
+    },
+    {
+      fieldName: 'email',
+      label: '邮箱',
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入邮箱',
+      },
+      formItemClass: 'col-span-1',
+    },
+    {
+      fieldName: 'phone',
+      label: '电话',
+      component: 'Input',
+      componentProps: {
+        placeholder: '请输入电话',
+      },
+      formItemClass: 'col-span-1',
+    },
+    {
+      fieldName: 'btn',
+      label: '',
+      component: () => {
+        return h(
+          'div',
+          {},
+          h(
+            Button,
+            {
+              type: 'primary',
+              onClick: () => {
+                formApi.validate().then(async (e: any) => {
+                  if (e.valid) {
+                    const values = await formApi.getValues();
+                    loading.value = true;
+                    const postParams = unref(modelRef);
+                    Object.assign(postParams, values);
+                    UserApi.editDetail(postParams as UserApi.RecordItem)
+                      .then(() => {
+                        message.success('保存成功');
+                        userStore.setUserInfo({
+                          ...userStore.userInfo,
+                          ...values,
+                        } as any);
+                      })
+                      .finally(() => {
+                        loading.value = false;
+                      });
+                  }
+                });
+              },
+              loading: loading.value,
+            },
+            {
+              default() {
+                return '更新信息';
+              },
+            },
+          ),
+        );
+      },
+      componentProps: {},
+      formItemClass: 'col-span-1',
+    },
+  ],
+});
+
+onMounted(async () => {
+  const data = await UserApi.getDetail(
+    (userStore.userInfo?.userId || 0) as number,
+  );
+  if (data) {
+    modelRef.value = data;
+    formApi.setValues({ ...data });
+  }
+});
+</script>
+<template>
+  <BasicForm />
+</template>
+<style lang="less" scoped></style>

+ 131 - 0
apps/web-baicai/src/views/system/personal/components/update-password.vue

@@ -0,0 +1,131 @@
+<script setup lang="ts">
+import { h, ref } from 'vue';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenForm, z } from '#/adapter/form';
+import { UserApi } from '#/api';
+
+const loading = ref<boolean>(false);
+
+const [BasicForm, formApi] = useVbenForm({
+  wrapperClass: 'grid-cols-1 w-[60%]', // 24栅格,
+  commonConfig: {
+    formItemClass: 'col-span-1',
+    labelWidth: 100,
+  },
+  showDefaultActions: false,
+  submitButtonOptions: {
+    content: '更新密码',
+  },
+  resetButtonOptions: {
+    show: false,
+  },
+  schema: [
+    {
+      fieldName: 'sourcePassword',
+      label: '旧密码',
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入旧密码',
+      },
+      formItemClass: 'col-span-1',
+      rules: 'required',
+    },
+    {
+      fieldName: 'password',
+      label: '新密码',
+      help: '5-18位数字、字母、特殊字符组成。',
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入新密码',
+      },
+      formItemClass: 'col-span-1',
+      rules: z
+        .string()
+        .regex(/[\w!@#$%^&*]{5,18}/, '密码由5-18位数字、字母、特殊字符组成。'),
+    },
+    {
+      fieldName: 'confirmPassword',
+      label: '确认密码',
+      help: '5-18位数字、字母、特殊字符组成。',
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入确认密码',
+      },
+      formItemClass: 'col-span-1',
+      rules: z
+        .string()
+        .regex(/[\w!@#$%^&*]{5,18}/, '密码由5-18位数字、字母、特殊字符组成。'),
+      dependencies: {
+        triggerFields: ['confirmPassword'],
+        rules: (values) => {
+          return z
+            .string()
+            .regex(
+              /[\w!@#$%^&*]{5,18}/,
+              '密码由5-18位数字、字母、特殊字符组成。',
+            )
+            .refine(
+              (confirmPassword) => {
+                return confirmPassword === values.password;
+              },
+              {
+                message: '确认密码必须与密码一致',
+              },
+            );
+        },
+      },
+    },
+    {
+      fieldName: 'btn',
+      label: '',
+      component: () => {
+        return h(
+          'div',
+          {},
+          h(
+            Button,
+            {
+              type: 'primary',
+              onClick: () => {
+                formApi.validate().then(async (e: any) => {
+                  if (e.valid) {
+                    const values = await formApi.getValues();
+                    loading.value = true;
+                    UserApi.updatePassword(values)
+                      .then(() => {
+                        message.success('密码修改成功');
+                        formApi.resetForm();
+                      })
+                      .finally(() => {
+                        loading.value = false;
+                      });
+                  }
+                });
+              },
+              loading: loading.value,
+            },
+            {
+              default() {
+                return '修改密码';
+              },
+            },
+          ),
+        );
+      },
+      formItemClass: 'col-span-1',
+    },
+  ],
+});
+
+defineExpose({
+  getFormApi() {
+    return formApi;
+  },
+});
+</script>
+<template>
+  <BasicForm />
+</template>
+<style lang="less" scoped></style>

+ 47 - 0
apps/web-baicai/src/views/system/personal/index.vue

@@ -0,0 +1,47 @@
+<script lang="ts" setup>
+import { ref } from 'vue';
+
+import { Page } from '@vben/common-ui';
+import { useUserStore } from '@vben/stores';
+
+import { Card, TabPane, Tabs } from 'ant-design-vue';
+
+import UpdateAvatar from './components/update-avatar.vue';
+import UpdateInfo from './components/update-info.vue';
+import UpdatePassword from './components/update-password.vue';
+
+const userStore = useUserStore();
+const updatePasswordRef = ref();
+
+const handleChange = (key: any) => {
+  if (key === '2') {
+    // 重置校验
+    updatePasswordRef.value?.getFormApi()?.resetValidate();
+  }
+};
+</script>
+
+<template>
+  <Page>
+    <div class="grid grid-cols-3 gap-4">
+      <Card class="col-span-1">
+        <div class="flex justify-center">
+          <UpdateAvatar :avatar="userStore.userInfo?.avatar" />
+        </div>
+        <div class="flex justify-center p-[8px]">
+          {{ userStore.userInfo?.realName }}
+        </div>
+      </Card>
+      <Card class="col-span-2">
+        <Tabs @change="handleChange">
+          <TabPane key="1" tab="基本设置">
+            <UpdateInfo />
+          </TabPane>
+          <TabPane key="2" tab="修改密码">
+            <UpdatePassword ref="updatePasswordRef" />
+          </TabPane>
+        </Tabs>
+      </Card>
+    </div>
+  </Page>
+</template>

+ 15 - 2
apps/web-baicai/src/views/system/user/components/edit.vue

@@ -7,6 +7,7 @@ import { message } from 'ant-design-vue';
 
 import { useVbenForm } from '#/adapter/form';
 import { UserApi } from '#/api';
+import { encrypt } from '#/utils';
 
 import { formOptions } from '../data.config';
 
@@ -17,7 +18,7 @@ const emit = defineEmits(['success']);
 const modelRef = ref<Record<string, any>>({});
 const isUpdate = ref(true);
 
-const [Form, { validate, setValues, getValues }] = useVbenForm({
+const [Form, { validate, setValues, getValues, updateSchema }] = useVbenForm({
   showDefaultActions: false,
   ...formOptions,
 });
@@ -35,7 +36,11 @@ const [Modal, { close, setState, getData }] = useVbenModal({
         const values = await getValues();
         setState({ confirmLoading: true });
         const postParams = unref(modelRef);
-        Object.assign(postParams, values);
+        const data: any = {};
+        if (values.password) {
+          data.password = encrypt(values.password);
+        }
+        Object.assign(postParams, values, { ...data });
         await (unref(isUpdate)
           ? UserApi.editDetail(postParams as UserApi.RecordItem)
           : UserApi.addDetail(postParams as UserApi.BasicRecordItem));
@@ -62,7 +67,15 @@ const [Modal, { close, setState, getData }] = useVbenModal({
         const entity = await UserApi.getDetail(data.baseData.id);
         modelRef.value = { ...entity };
         setValues(entity);
+        updateSchema([
+          { fieldName: 'password', componentProps: { disabled: true } },
+        ]);
+      } else {
+        updateSchema([
+          { fieldName: 'password', componentProps: { disabled: false } },
+        ]);
       }
+
       setState({ loading: false });
     }
   },

+ 1 - 1
apps/web-baicai/src/views/system/user/data.config.ts

@@ -83,7 +83,7 @@ export const formOptions: VbenFormProps = {
       rules: 'required',
     },
     {
-      component: 'Input',
+      component: 'InputPassword',
       componentProps: {
         placeholder: '请输入密码',
       },

+ 2 - 2
packages/effects/request/src/request-client/preset-interceptors.ts

@@ -20,9 +20,9 @@ export const authenticateResponseInterceptor = ({
 }): ResponseInterceptorConfig => {
   return {
     rejected: async (error) => {
-      const { config, response } = error;
+      const { config, response, data } = error;
       // 如果不是 401 错误,直接抛出异常
-      if (response?.status !== 401) {
+      if (response?.status !== 401 && data?.code !== 401) {
         throw error;
       }
       // 判断是否启用了 refreshToken 功能