Browse Source

chore(@six/health-remedy): 新增中医智能辅诊项目

张田田 1 month ago
parent
commit
5de0c6d694
64 changed files with 3561 additions and 0 deletions
  1. 8 0
      apps/health-remedy/.env
  2. 15 0
      apps/health-remedy/.env.development
  3. 21 0
      apps/health-remedy/.env.production
  4. 18 0
      apps/health-remedy/index.html
  5. 46 0
      apps/health-remedy/package.json
  6. 1 0
      apps/health-remedy/postcss.config.mjs
  7. 51 0
      apps/health-remedy/public/database/menu.json
  8. 0 0
      apps/health-remedy/public/favicon.ico
  9. 107 0
      apps/health-remedy/src/adapter/component/Avatar.vue
  10. 214 0
      apps/health-remedy/src/adapter/component/index.ts
  11. 49 0
      apps/health-remedy/src/adapter/form.ts
  12. 305 0
      apps/health-remedy/src/adapter/vxe-table.ts
  13. 87 0
      apps/health-remedy/src/api/index.ts
  14. 87 0
      apps/health-remedy/src/api/method/access.ts
  15. 182 0
      apps/health-remedy/src/api/method/business.ts
  16. 20 0
      apps/health-remedy/src/api/method/common.ts
  17. 141 0
      apps/health-remedy/src/api/method/system.ts
  18. 43 0
      apps/health-remedy/src/api/model/department.ts
  19. 46 0
      apps/health-remedy/src/api/model/doctor.ts
  20. 22 0
      apps/health-remedy/src/api/model/index.ts
  21. 42 0
      apps/health-remedy/src/api/model/menu.ts
  22. 25 0
      apps/health-remedy/src/api/model/role.ts
  23. 32 0
      apps/health-remedy/src/api/model/user.ts
  24. 39 0
      apps/health-remedy/src/app.vue
  25. 77 0
      apps/health-remedy/src/bootstrap.ts
  26. 102 0
      apps/health-remedy/src/components/import/database.modal.vue
  27. 81 0
      apps/health-remedy/src/core/authentication/login.vue
  28. 7 0
      apps/health-remedy/src/core/fallback/coming-soon.vue
  29. 9 0
      apps/health-remedy/src/core/fallback/forbidden.vue
  30. 9 0
      apps/health-remedy/src/core/fallback/internal-error.vue
  31. 11 0
      apps/health-remedy/src/core/fallback/not-found.vue
  32. 9 0
      apps/health-remedy/src/core/fallback/offline.vue
  33. 26 0
      apps/health-remedy/src/layouts/auth.vue
  34. 79 0
      apps/health-remedy/src/layouts/basic.vue
  35. 6 0
      apps/health-remedy/src/layouts/index.ts
  36. 102 0
      apps/health-remedy/src/locales/index.ts
  37. 5 0
      apps/health-remedy/src/locales/langs/zh-CN/authentication.json
  38. 30 0
      apps/health-remedy/src/locales/langs/zh-CN/business.json
  39. 5 0
      apps/health-remedy/src/locales/langs/zh-CN/common.json
  40. 9 0
      apps/health-remedy/src/locales/langs/zh-CN/page.json
  41. 24 0
      apps/health-remedy/src/locales/langs/zh-CN/system.json
  42. 12 0
      apps/health-remedy/src/locales/langs/zh-CN/table.json
  43. 31 0
      apps/health-remedy/src/main.ts
  44. 38 0
      apps/health-remedy/src/preferences.ts
  45. 48 0
      apps/health-remedy/src/router/access.ts
  46. 143 0
      apps/health-remedy/src/router/guard.ts
  47. 37 0
      apps/health-remedy/src/router/index.ts
  48. 63 0
      apps/health-remedy/src/router/routes/core.ts
  49. 40 0
      apps/health-remedy/src/router/routes/index.ts
  50. 77 0
      apps/health-remedy/src/store/auth.ts
  51. 1 0
      apps/health-remedy/src/store/index.ts
  52. 124 0
      apps/health-remedy/src/views/system/role/data.ts
  53. 156 0
      apps/health-remedy/src/views/system/role/list.vue
  54. 133 0
      apps/health-remedy/src/views/system/role/modules/form.vue
  55. 140 0
      apps/health-remedy/src/views/system/user/data.ts
  56. 106 0
      apps/health-remedy/src/views/system/user/list.vue
  57. 90 0
      apps/health-remedy/src/views/system/user/modules/form.vue
  58. 1 0
      apps/health-remedy/tailwind.config.mjs
  59. 12 0
      apps/health-remedy/tsconfig.json
  60. 10 0
      apps/health-remedy/tsconfig.node.json
  61. 25 0
      apps/health-remedy/vite.config.mts
  62. 2 0
      package.json
  63. 76 0
      pnpm-lock.yaml
  64. 4 0
      vben-admin.code-workspace

+ 8 - 0
apps/health-remedy/.env

@@ -0,0 +1,8 @@
+# 应用标题
+VITE_APP_TITLE=中医智能辅助诊疗系统
+
+# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
+VITE_APP_NAMESPACE=@six.admin/health_remedy
+
+# 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密
+VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key

+ 15 - 0
apps/health-remedy/.env.development

@@ -0,0 +1,15 @@
+# 端口号
+VITE_PORT=5666
+
+VITE_BASE=/
+
+# 接口地址
+VITE_GLOB_API_URL=
+# 智能导诊接口地址
+VITE_GLOB_API_HEALTH_REMEDY=/dz
+
+# 是否打开 devtools,true 为打开,false 为关闭
+VITE_DEVTOOLS=true
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true

+ 21 - 0
apps/health-remedy/.env.production

@@ -0,0 +1,21 @@
+VITE_BASE=/dz/p/
+
+# 接口地址
+VITE_GLOB_API_URL=
+# 智能导诊接口地址
+VITE_GLOB_API_HOSPITAL_GUIDE=/dz
+
+# 是否开启压缩,可以设置为 none, brotli, gzip
+VITE_COMPRESS=brotli,gzip
+
+# 是否开启 PWA
+VITE_PWA=false
+
+# vue-router 的模式
+VITE_ROUTER_HISTORY=hash
+
+# 是否注入全局loading
+VITE_INJECT_APP_LOADING=true
+
+# 打包后是否生成dist.zip
+VITE_ARCHIVER=true

+ 18 - 0
apps/health-remedy/index.html

@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="zh">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <meta name="renderer" content="webkit" />
+    <meta
+      name="viewport"
+      content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
+    />
+    <title><%= VITE_APP_TITLE %></title>
+    <link rel="icon" href="/favicon.ico" />
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 46 - 0
apps/health-remedy/package.json

@@ -0,0 +1,46 @@
+{
+  "name": "@six/health-remedy",
+  "version": "0.0.0",
+  "homepage": "",
+  "bugs": "",
+  "license": "MIT",
+  "type": "module",
+  "scripts": {
+    "build": "pnpm vite build --mode production",
+    "build:analyze": "pnpm vite build --mode analyze",
+    "dev": "pnpm vite --mode development",
+    "preview": "vite preview",
+    "typecheck": "vue-tsc --noEmit --skipLibCheck"
+  },
+  "imports": {
+    "#/*": "./src/*"
+  },
+  "dependencies": {
+    "@six/request": "workspace:*",
+    "@vben-core/menu-ui": "workspace:*",
+    "@vben/access": "workspace:*",
+    "@vben/common-ui": "workspace:*",
+    "@vben/constants": "workspace:*",
+    "@vben/hooks": "workspace:*",
+    "@vben/icons": "workspace:*",
+    "@vben/layouts": "workspace:*",
+    "@vben/locales": "workspace:*",
+    "@vben/plugins": "workspace:*",
+    "@vben/preferences": "workspace:*",
+    "@vben/stores": "workspace:*",
+    "@vben/styles": "workspace:*",
+    "@vben/types": "workspace:*",
+    "@vben/utils": "workspace:*",
+    "@vueuse/core": "catalog:",
+    "@vueuse/router": "catalog:",
+    "alova": "catalog:",
+    "ant-design-vue": "catalog:",
+    "dayjs": "catalog:",
+    "pinia": "catalog:",
+    "vue": "catalog:",
+    "vue-router": "catalog:"
+  },
+  "devDependencies": {
+    "@vben-core/typings": "workspace:*"
+  }
+}

+ 1 - 0
apps/health-remedy/postcss.config.mjs

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

+ 51 - 0
apps/health-remedy/public/database/menu.json

@@ -0,0 +1,51 @@
+[
+  {
+    "meta": {
+      "icon": "charm:organisation",
+      "order": 2,
+      "title": "business.dept.title"
+    },
+    "name": "BusinessDepartment",
+    "path": "/business/dept",
+    "component": "/business/department/list"
+  },
+  {
+    "meta": {
+      "icon": "mdi:account",
+      "order": 3,
+      "title": "business.doctor.title"
+    },
+    "name": "BusinessDoctor",
+    "path": "/business/doctor",
+    "component": "/business/doctor/list"
+  },
+  {
+    "meta": {
+      "icon": "ion:settings-outline",
+      "order": 9997,
+      "title": "system.title"
+    },
+    "name": "System",
+    "path": "/system",
+    "children": [
+      {
+        "path": "/system/role",
+        "name": "SystemRole",
+        "meta": {
+          "icon": "mdi:account-group",
+          "title": "system.role.title"
+        },
+        "component": "/system/role/list"
+      },
+      {
+        "path": "/system/user",
+        "name": "SystemUser",
+        "meta": {
+          "icon": "charm:organisation",
+          "title": "system.user.title"
+        },
+        "component": "/system/user/list"
+      }
+    ]
+  }
+]

+ 0 - 0
apps/health-remedy/public/favicon.ico


+ 107 - 0
apps/health-remedy/src/adapter/component/Avatar.vue

@@ -0,0 +1,107 @@
+<script setup lang="ts">
+import type { UploadChangeParam } from 'ant-design-vue';
+
+import { ref, watchEffect } from 'vue';
+
+import { useRequest } from 'alova/client';
+import { Modal, Upload } from 'ant-design-vue';
+
+import { uploadFileMethod } from '#/api/method/common';
+
+interface UploadFile extends File {
+  uid?: string;
+  status: 'done' | 'error' | 'removed' | 'success' | 'uploading';
+  thumbUrl: string;
+  url: string;
+}
+
+defineOptions({
+  inheritAttrs: false,
+});
+
+function getBase64(img: Blob, callback: (base64Url: string) => void) {
+  const reader = new FileReader();
+  reader.addEventListener('load', () => callback(reader.result as string), {
+    once: true,
+  });
+  reader.readAsDataURL(img);
+}
+
+const { loading, send: upload } = useRequest(uploadFileMethod, {
+  immediate: false,
+});
+
+const avatar = defineModel<string | void>('value', { default: void 0 });
+const fileList = ref<UploadFile[]>([]);
+watchEffect(() => {
+  const url = avatar.value ?? '';
+  if (url) {
+    const name = url.split('/').pop() ?? 'avatar.png';
+    fileList.value = [{ uid: '-1', status: 'done', name, url } as UploadFile];
+  } else {
+    fileList.value = [];
+  }
+});
+
+const previewVisible = ref(false);
+const previewImage = ref('');
+const handleCancel = () => {
+  previewVisible.value = false;
+};
+const onPreviewHandle = async (file: UploadFile) => {
+  previewImage.value = file.url || file.thumbUrl;
+  previewVisible.value = true;
+};
+
+const onChangeHandle = async (info: UploadChangeParam<UploadFile>) => {
+  info.file.status ??= 'uploading';
+  if (info.file.status === 'uploading') {
+    getBase64(info.file, (thumbUrl) => {
+      info.file.thumbUrl = thumbUrl;
+    });
+    try {
+      const url = await upload(info.file);
+      info.file.status = 'done';
+      avatar.value = url;
+    } catch {
+      info.file.status = 'error';
+      avatar.value = void 0;
+    }
+  }
+};
+
+const onRemoveHandle = () => {
+  avatar.value = void 0;
+};
+</script>
+
+<template>
+  <div>
+    <Upload
+      v-bind="$attrs"
+      v-model:file-list="fileList"
+      accept="image/*"
+      :max-count="1"
+      name="avatar"
+      list-type="picture-card"
+      class="avatar-uploader"
+      :disabled="loading"
+      :before-upload="() => false"
+      @change="onChangeHandle"
+      @remove="onRemoveHandle"
+      @preview="onPreviewHandle"
+    >
+      <div v-if="!fileList?.length" class="ant-upload-text">上传</div>
+    </Upload>
+    <Modal
+      :open="previewVisible"
+      title="头像"
+      :footer="null"
+      @cancel="handleCancel"
+    >
+      <img alt="example" style="width: 100%" :src="previewImage" />
+    </Modal>
+  </div>
+</template>
+
+<style scoped></style>

+ 214 - 0
apps/health-remedy/src/adapter/component/index.ts

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

+ 49 - 0
apps/health-remedy/src/adapter/form.ts

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

+ 305 - 0
apps/health-remedy/src/adapter/vxe-table.ts

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

+ 87 - 0
apps/health-remedy/src/api/index.ts

@@ -0,0 +1,87 @@
+import type { Recordable } from '@vben-core/typings';
+
+import createRequestClient from '@six/request';
+import { message } from 'ant-design-vue';
+
+import { useAuthStore } from '#/store';
+
+import '@six/request/alova';
+
+export * from './method/access';
+export * from './method/business';
+export * from './method/common';
+export * from './method/system';
+
+export const http = createRequestClient({
+  id: import.meta.env.VITE_APP_NAMESPACE?.split('/').pop() ?? 'health-remedy',
+  transform(body, method) {
+    /* prettier-ignore */
+    if (body === null || typeof body !== 'object') return { code: 0, data: body, message: 'ok' };
+    const { ResultCode: code, ResultInfo: message, Data: data, ...r } = body;
+    const result = { code, message, data, ...r };
+
+    if (
+      result.data?.TotalPageCount !== void 0 &&
+      Array.isArray(result.data?.Items)
+    ) {
+      const {
+        TotalPageCount: total,
+        PageIndex: page,
+        PageSize: size,
+        Items: items,
+      } = result.data;
+      result.data = { total, items, data: { page, size, total } };
+    }
+
+    /* 额外处理登录接口 */
+    if (method.meta?.login || method.meta?.authRole === 'login') {
+      const { token, ...data } = result.data ?? {};
+      result.data = {
+        accessToken: token,
+        refreshToken: null,
+        ...data,
+      };
+    }
+    return result;
+  },
+});
+http.interceptor('error', async (error) => {
+  if (error?.code === 401) await useAuthStore().logout();
+  message.error(error.message ?? `服务错误 (${error.code})`).then();
+});
+
+export const database = createRequestClient({
+  baseURL: `${import.meta.env.BASE_URL}database`,
+  transform(body) {
+    return { code: 0, data: body, message: 'ok' } as any;
+  },
+});
+
+export type TransformData<T = any> = Recordable<T>;
+
+export interface TransformList<T = TransformData> {
+  total: number;
+  items: T[];
+  data?: { page: number; size: number; total: number };
+}
+
+export interface TransformBody<T> {
+  code: number;
+  data: T;
+  message?: string;
+}
+
+export interface TransformBlob {
+  fileName: string;
+  source: Blob;
+}
+
+export interface TransformRecord {
+  id: string;
+  createUser?: string;
+  createTime?: string;
+  updateUser?: string;
+  updateTime?: string;
+  lastTime?: string;
+  lastUser?: string;
+}

+ 87 - 0
apps/health-remedy/src/api/method/access.ts

@@ -0,0 +1,87 @@
+import type { UserInfo } from '@vben/types';
+
+import type { SystemModel, TransformData } from '#/api';
+
+import { http } from '#/api';
+import { fromRole } from '#/api/model';
+import { fromMenus } from '#/api/model/menu';
+
+export namespace AccessModel {
+  export interface LoginParams {
+    password: 'string';
+    username: 'string';
+  }
+
+  export interface LoginResponse {
+    accessToken: string;
+    refreshToken?: string;
+
+    [K: string]: any;
+  }
+}
+
+export function loginMethod(data: AccessModel.LoginParams) {
+  return http.post<AccessModel.LoginResponse>(`/login`, data, {
+    meta: { login: true, visitor: true },
+  });
+}
+
+export function getAccessMenuMethod(permissions?: string[]) {
+  return http.post<SystemModel.Menu[], TransformData[]>(
+    `/admin/right_RoleMgr/allMenu`,
+    void 0,
+    {
+      transform(data) {
+        const menus = fromMenus(data);
+        if (!permissions?.length) return menus;
+
+        const forEach = (
+          permissions: Set<string>,
+          menus: SystemModel.Menu[],
+          parentMenu: SystemModel.Menu[] = [],
+        ) => {
+          for (const menu of menus) {
+            const id = menu.id;
+            if (permissions.has(id)) {
+              permissions.delete(id);
+              if (menu.type === 'menu') parentMenu.push(menu);
+              else if (menu.type === 'catalog' && menu.children?.length) {
+                const parent = { ...menu, children: [] };
+                parentMenu.push(parent);
+                forEach(permissions, menu.children, parent.children);
+              }
+            } else if (menu.type === 'catalog' && menu.children?.length) {
+              forEach(permissions, menu.children, parentMenu);
+            }
+            if (permissions.size === 0) break;
+          }
+          return parentMenu;
+        };
+
+        return forEach(new Set(permissions), menus);
+      },
+    },
+  );
+}
+
+export function getUserInfoMethod(token?: string) {
+  return http.get<UserInfo & { roles: SystemModel.Role[] }, TransformData>(
+    `/getInfo`,
+    {
+      headers: { Authorization: token },
+      transform(data, headers) {
+        return {
+          avatar: data?.headImage,
+          realName: '',
+          roles: data?.roles?.map(fromRole) ?? [],
+          userId: data?.userid,
+          username: data?.username,
+          // user
+          token: token ?? headers.get('authorization') ?? '',
+          homePath: '',
+          desc: '',
+        } satisfies UserInfo;
+      },
+    },
+  );
+}

+ 182 - 0
apps/health-remedy/src/api/method/business.ts

@@ -0,0 +1,182 @@
+import type {
+  TransformBlob,
+  TransformBody,
+  TransformData,
+  TransformList,
+  TransformRecord,
+} from '#/api';
+
+import { downloadFileFromBlob } from '@vben/utils';
+
+import { http } from '#/api';
+import {
+  fromDepartment,
+  fromDoctor,
+  toDepartment,
+  toDoctor,
+} from '#/api/model';
+
+export namespace BusinessModel {
+  export interface Department extends TransformRecord {
+    id: string;
+    name: string;
+    code?: string;
+    description?: string;
+    organization?: { name: string };
+    parent?: Department;
+    children?: Department[];
+    registerLink?: string;
+  }
+  export interface Doctor extends TransformRecord {
+    id: string;
+    name: string;
+    code?: string;
+    avatar?: string;
+    description?: string;
+    worker?: string;
+    department?: Department;
+    titleOfClinical?: string;
+    titleOfTeach?: string;
+    titleOf?: string;
+    adeptAt?: string;
+    registerLink?: string;
+  }
+}
+
+export function listDepartmentsMethod(
+  page = 1,
+  size = 20,
+  query?: Partial<BusinessModel.Department>,
+) {
+  return http.post<TransformList<BusinessModel.Department>, TransformList>(
+    `/basis/department/listPage`,
+    toDepartment(query),
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromDepartment(item)) };
+      },
+    },
+  );
+}
+
+export function treeDepartmentsMethod() {
+  return http.post<BusinessModel.Department[], TransformData[]>(
+    `/basis/department/list`,
+    void 0,
+    {
+      transform(data) {
+        return data.map((item) => fromDepartment(item));
+      },
+    },
+  );
+}
+
+export function editDepartmentMethod(data: Partial<BusinessModel.Department>) {
+  return http.post(
+    data?.id ? `/basis/department/update` : `/basis/department/add`,
+    toDepartment(data),
+  );
+}
+
+export function deleteDepartmentMethod(
+  data: Pick<BusinessModel.Department, 'id'>,
+) {
+  return deleteDepartmentsMethod([data]);
+}
+
+export function deleteDepartmentsMethod(
+  params: Pick<BusinessModel.Department, 'id'>[],
+) {
+  return http.post(`/basis/department/batchDelete`, void 0, {
+    params: { ids: params.map((item) => item.id).join(',') },
+  });
+}
+
+export function downloadDepartmentTemplateMethod(filename = '科室模板.xlsx') {
+  return http.get(`/basis/department/downExcel`, {
+    params: { fileName: filename },
+    transform(data: TransformBlob) {
+      downloadFileFromBlob(data);
+      return data;
+    },
+  });
+}
+export function uploadDepartmentFileMethod(file: File) {
+  const data = new FormData();
+  data.append('file', file);
+  return http.post(`/basis/department/importExcel`, data, {
+    meta: { notParseResponseBody: true },
+    transform(data: TransformBody<unknown>) {
+      if (data.code === 0) {
+        const [_, count = ''] = data.message?.match(/入库成功(\d+)条/) ?? [];
+        if (+count > 0)
+          return { count: +count, message: data.message ?? `导入成功` };
+      }
+      // eslint-disable-next-line no-throw-literal
+      throw { message: data.message ?? `导入失败` };
+    },
+  });
+}
+
+export function listDoctorsMethod(
+  page = 1,
+  size = 20,
+  query?: Partial<BusinessModel.Doctor>,
+) {
+  return http.post<TransformList<BusinessModel.Doctor>, TransformList>(
+    `/basis/doctor/listPage`,
+    toDoctor(query),
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromDoctor(item)) };
+      },
+    },
+  );
+}
+
+export function editDoctorMethod(data: Partial<BusinessModel.Doctor>) {
+  return http.post(
+    data?.id ? `/basis/doctor/update` : `/basis/doctor/add`,
+    toDoctor(data),
+  );
+}
+
+export function deleteDoctorMethod(data: Pick<BusinessModel.Doctor, 'id'>) {
+  return deleteDoctorsMethod([data]);
+}
+
+export function deleteDoctorsMethod(
+  params: Pick<BusinessModel.Doctor, 'id'>[],
+) {
+  return http.post(`/basis/doctor/batchDelete`, void 0, {
+    params: { ids: params.map((item) => item.id).join(',') },
+  });
+}
+
+export function downloadDoctorTemplateMethod(filename = '医生模板.xlsx') {
+  return http.get(`/basis/doctor/downExcel`, {
+    params: { fileName: filename },
+    transform(data: TransformBlob) {
+      downloadFileFromBlob(data);
+      return data;
+    },
+  });
+}
+export function uploadDoctorFileMethod(file: File) {
+  const data = new FormData();
+  data.append('file', file);
+  return http.post(`/basis/doctor/importExcel`, data, {
+    meta: { notParseResponseBody: true },
+    transform(data: TransformBody<unknown>) {
+      if (data.code === 0) {
+        const [_, count = ''] = data.message?.match(/入库成功(\d+)条/) ?? [];
+        if (+count > 0)
+          return { count: +count, message: data.message ?? `导入成功` };
+      }
+      // eslint-disable-next-line no-throw-literal
+      throw { message: data.message ?? `导入失败` };
+    },
+  });
+}

+ 20 - 0
apps/health-remedy/src/api/method/common.ts

@@ -0,0 +1,20 @@
+import type { TransformBody } from '#/api';
+
+import { http } from '#/api';
+
+export function uploadFileMethod(file: File) {
+  const data = new FormData();
+  data.append('file', file);
+  return http.post(`/common/upload`, data, {
+    meta: { notParseResponseBody: true },
+    transform(data: TransformBody<unknown>) {
+      if (data.code === 0 && data.message?.startsWith('http'))
+        return data.message;
+      else if (data.code === 0) {
+        return 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
+      }
+      // eslint-disable-next-line no-throw-literal
+      throw { message: data.message ?? `上传失败` };
+    },
+  });
+}

+ 141 - 0
apps/health-remedy/src/api/method/system.ts

@@ -0,0 +1,141 @@
+import type { RouteMeta } from 'vue-router';
+
+import type { TransformData, TransformList, TransformRecord } from '#/api';
+
+import { http } from '#/api';
+import { fromRole, fromUser, toRole, toUser } from '#/api/model';
+import { fromMenus } from '#/api/model/menu';
+
+export namespace SystemModel {
+  export interface Role extends TransformRecord {
+    [key: string]: any;
+
+    id: string;
+    name: string;
+    code?: string;
+    permissions: string[];
+    remark?: string;
+    status: 0 | 1;
+  }
+
+  export interface User extends TransformRecord {
+    id: string;
+    access: string;
+    name: string;
+    worker?: string;
+    mobile?: string;
+    roles?: Array<Role | string>;
+
+    password?: string;
+  }
+
+  export interface Menu {
+    type: 'button' | 'catalog' | 'menu';
+    id: string;
+    pid?: string;
+    name: string;
+    path: string;
+    component?: string;
+    meta: Partial<RouteMeta>;
+    children?: Menu[];
+  }
+}
+
+export function listRolesMethod(page = 1, size = 20, query?: TransformData) {
+  return http.post<TransformList<SystemModel.Role>, TransformList>(
+    `/admin/right_RoleMgr/listPain`,
+    query,
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromRole(item)) };
+      },
+    },
+  );
+}
+
+export function optionsRoleMethod() {
+  return http.get<SystemModel.Role[], TransformData[]>(
+    `/admin/right_RoleMgr/optionselect`,
+    {
+      transform(data) {
+        return data.map((item) => fromRole(item));
+      },
+    },
+  );
+}
+
+export function editRoleMethod(data: Partial<SystemModel.Role>) {
+  return http.post(
+    data.id ? `/admin/right_RoleMgr/update` : `/admin/right_RoleMgr/Add`,
+    toRole(data),
+  );
+}
+
+export function updateRoleStatusMethod(
+  id: string,
+  data: Partial<Omit<SystemModel.Role, 'id'>>,
+) {
+  const { pid, stateSel } = toRole({ ...data, id });
+  return http.put(`/admin/right_RoleMgr/changeStatus`, { pid, stateSel });
+}
+
+export function deleteRoleMethod(data: Pick<SystemModel.User, 'id'>) {
+  return deleteRolesMethod([data]);
+}
+
+export function deleteRolesMethod(params: Pick<SystemModel.User, 'id'>[]) {
+  return http.post(`/admin/right_RoleMgr/BatchDelete`, void 0, {
+    params: { ids: params.map((item) => item.id).join(',') },
+  });
+}
+
+export function listUsersMethod(page = 1, size = 20, query?: SystemModel.User) {
+  return http.post<TransformList<SystemModel.User>, TransformList>(
+    `/portal/userMgr/listPain`,
+    toUser(query),
+    {
+      params: { page, limit: size },
+      transform({ items, ...data }) {
+        return { ...data, items: items.map((item) => fromUser(item)) };
+      },
+    },
+  );
+}
+
+export function editUserMethod(data: Partial<SystemModel.User>) {
+  return http.post(
+    data?.id ? `/portal/userMgr/update` : `/portal/userMgr/Add`,
+    toUser(data),
+  );
+}
+
+export function getUserMethod(id: string) {
+  return http.get<SystemModel.User, TransformData>(`/portal/userMgr/${id}`, {
+    transform(data) {
+      return fromUser(data);
+    },
+  });
+}
+
+export function deleteUserMethod(data: Pick<SystemModel.User, 'id'>) {
+  return deleteUsersMethod([data]);
+}
+
+export function deleteUsersMethod(params: Pick<SystemModel.User, 'id'>[]) {
+  return http.post(`/portal/userMgr/BatchDelete`, void 0, {
+    params: { ids: params.map((item) => item.id).join(',') },
+  });
+}
+
+export function getMenusMethod() {
+  return http.post<SystemModel.Menu[], TransformData[]>(
+    `/admin/right_RoleMgr/allMenu`,
+    void 0,
+    {
+      transform(data) {
+        return fromMenus(data);
+      },
+    },
+  );
+}

+ 43 - 0
apps/health-remedy/src/api/model/department.ts

@@ -0,0 +1,43 @@
+import type { BusinessModel, TransformData } from '#/api';
+
+import { fromRow } from '#/api/model';
+
+export function fromDepartment(data?: TransformData): BusinessModel.Department {
+  return {
+    ...fromRow(data),
+    id: data?.pid,
+    name: data?.deptName,
+    code: data?.deptCode,
+    description: data?.introduce,
+    organization: data?.hospitalName
+      ? <any>{
+          code: data?.hospitalCode,
+          name: data?.hospitalName,
+        }
+      : void 0,
+    parent: data?.parentDeptName
+      ? <BusinessModel.Department>{
+          code: data?.parentDeptCode,
+          name: data?.parentDeptName,
+        }
+      : void 0,
+    children:
+      data?.children?.map((item: TransformData) => fromDepartment(item)) ?? [],
+    registerLink: data?.registerUrl,
+  };
+}
+
+export function toDepartment(
+  data?: Partial<BusinessModel.Department>,
+): TransformData {
+  return {
+    pid: data?.id,
+    deptName: data?.name,
+    deptCode: data?.code,
+    introduce: data?.description,
+    hospitalName: data?.organization?.name,
+    parentDeptCode: data?.parent?.code,
+    parentDeptName: data?.parent?.name,
+    registerUrl: data?.registerLink,
+  };
+}

+ 46 - 0
apps/health-remedy/src/api/model/doctor.ts

@@ -0,0 +1,46 @@
+import type { BusinessModel, TransformData } from '#/api';
+
+import { fromRow } from '#/api/model';
+
+export function fromDoctor(data?: TransformData): BusinessModel.Doctor {
+  return {
+    ...fromRow(data),
+    id: data?.id,
+    name: data?.doctorName,
+    description: data?.introduce,
+    titleOf: data?.title,
+    titleOfClinical: data?.clinicalTitle,
+    titleOfTeach: data?.teachTitle,
+    avatar: data?.headImage,
+    worker: data?.doctorCode,
+    adeptAt: data?.expertiseArea,
+    department: data?.deptName
+      ? <BusinessModel.Department>{
+          code: data?.deptCode,
+          name: data?.deptName,
+        }
+      : void 0,
+    registerLink: data?.registerUrl,
+  };
+}
+
+export function toDoctor(data?: Partial<BusinessModel.Doctor>): TransformData {
+  return {
+    id: data?.id,
+    doctorName: data?.name,
+    doctorCode: data?.worker,
+    headImage: data?.avatar,
+
+    title: data?.titleOf,
+    clinicalTitle: data?.titleOfClinical,
+    teachTitle: data?.titleOfTeach,
+
+    deptCode: data?.department?.code,
+    deptName: data?.department?.name,
+
+    expertiseArea: data?.adeptAt,
+    introduce: data?.description,
+
+    registerUrl: data?.registerLink,
+  };
+}

+ 22 - 0
apps/health-remedy/src/api/model/index.ts

@@ -0,0 +1,22 @@
+import type { TransformData, TransformRecord } from '#/api';
+
+export * from './department';
+export * from './doctor';
+export * from './role';
+export * from './user';
+
+export function fromRow(data?: TransformData): TransformRecord {
+  const createUser = data?.createUser;
+  const createTime = data?.createTime ?? data?.createDate;
+  const updateUser = data?.updateUser;
+  const updateTime = data?.updateTime ?? data?.updateDate;
+  return {
+    id: data?.id,
+    createUser,
+    createTime,
+    updateUser,
+    updateTime,
+    lastTime: updateTime || createTime,
+    lastUser: updateUser || createUser,
+  };
+}

+ 42 - 0
apps/health-remedy/src/api/model/menu.ts

@@ -0,0 +1,42 @@
+import type { SystemModel, TransformData } from '#/api';
+
+export function fromMenus(menus: TransformData[]): SystemModel.Menu[] {
+  const getType = (menu: TransformData): SystemModel.Menu['type'] => {
+    if (menu.type) return menu.type;
+    if (menu.component && menu.children === null) return 'menu';
+    return menu.component ? 'catalog' : 'button';
+  };
+  return Array.isArray(menus)
+    ? menus
+        .map((menu: TransformData) => {
+          menu.meta ??= {};
+          menu.meta.order ??= menu?.orderNum ?? -1;
+          return {
+            type: getType(menu),
+            id: menu.id ?? menu.meta.id,
+            pid: menu.parentId,
+            name: menu.name,
+            path: menu.path,
+            component: menu.component,
+            meta: fromMenuMeta(menu.meta),
+            children: fromMenus(menu.children),
+          } satisfies SystemModel.Menu;
+        })
+        .sort((a, b) => (a.meta.order ?? -1) - (b.meta.order ?? -1))
+    : [];
+}
+
+type MenuMeta = SystemModel.Menu['meta'];
+
+export function getDefaultMenuMeta(meta?: MenuMeta): MenuMeta {
+  return Object.assign(
+    {
+      keepAlive: true,
+    },
+    meta,
+  );
+}
+
+function fromMenuMeta(meta: TransformData): MenuMeta {
+  return getDefaultMenuMeta(meta satisfies MenuMeta);
+}

+ 25 - 0
apps/health-remedy/src/api/model/role.ts

@@ -0,0 +1,25 @@
+import type { SystemModel, TransformData } from '#/api';
+
+import { fromRow } from '#/api/model/index';
+
+export function fromRole(data?: TransformData): SystemModel.Role {
+  return {
+    ...fromRow(data),
+    id: data?.pid,
+    name: data?.rolename,
+    code: data?.rolecode,
+    remark: data?.remark,
+    status: ({ '0': 1, '1': 0 } as const)[<string>data?.stateSel ?? 1] ?? 1,
+    permissions: Array.isArray(data?.menuIds) ? data?.menuIds : [],
+  };
+}
+
+export function toRole(data?: Partial<SystemModel.Role>): TransformData {
+  return {
+    pid: data?.id,
+    rolename: data?.name,
+    remark: data?.remark,
+    stateSel: data?.status === 0 ? '1' : '0',
+    menuIds: data?.permissions ?? [],
+  };
+}

+ 32 - 0
apps/health-remedy/src/api/model/user.ts

@@ -0,0 +1,32 @@
+import type { SystemModel, TransformData } from '#/api';
+
+import { fromRole, fromRow, toRole } from '#/api/model';
+
+export function fromUser(data?: TransformData): SystemModel.User {
+  return {
+    ...fromRow(data),
+    id: data?.pid,
+    access: data?.userid,
+    name: data?.username,
+    worker: data?.jobnumber,
+    mobile: data?.mobile,
+    roles: data?.roles?.map((item: TransformData) => fromRole(item)) ?? [],
+  };
+}
+
+export function toUser(data?: Partial<SystemModel.User>): TransformData {
+  const roles =
+    data?.roles?.map((item) =>
+      typeof item === 'string' ? { pid: item } : toRole(item),
+    ) ?? [];
+  return {
+    pid: data?.id,
+    userid: data?.access,
+    username: data?.name,
+    password: data?.password,
+    jobnumber: data?.worker,
+    mobile: data?.mobile,
+    roles: roles.length > 0 ? roles : void 0,
+    roleIds: roles.map((item) => item.pid).join(',') || void 0,
+  };
+}

+ 39 - 0
apps/health-remedy/src/app.vue

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

+ 77 - 0
apps/health-remedy/src/bootstrap.ts

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

+ 102 - 0
apps/health-remedy/src/components/import/database.modal.vue

@@ -0,0 +1,102 @@
+<script setup lang="ts">
+import type { UploadChangeParam } from 'ant-design-vue';
+
+import { shallowRef, triggerRef } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+import { FilePlus } from '@vben/icons';
+import { $t } from '@vben/locales';
+
+import { message, UploadDragger } from 'ant-design-vue';
+
+export interface ImportDatabaseProps<U = { count: number; message: string }> {
+  accept?: string;
+  upload?: (file: File) => Promise<U>;
+}
+
+defineOptions({
+  name: 'ImportDatabaseModal',
+});
+
+const emit = defineEmits(['success']);
+
+const props = shallowRef<ImportDatabaseProps>({
+  accept: '*/*',
+});
+
+const [Modal, modalApi] = useVbenModal({
+  showConfirmButton: false,
+  async onConfirm() {},
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = modalApi.getData<ImportDatabaseProps>();
+      Object.assign(props.value, data);
+      triggerRef(props);
+    }
+  },
+});
+
+const onAcceptError = (_files: File[]) => {
+  message.warn(`请上传正确格式的文件`);
+};
+
+async function onChangeHandle(info: UploadChangeParam<File>) {
+  const accept = props.value.accept ?? '*/*';
+  if (info.file.type && accept !== '*/*' && !accept.includes(info.file.type))
+    return onAcceptError(info.fileList);
+
+  modalApi.lock();
+  try {
+    const data = await props.value.upload?.(info.file);
+    if (data?.count) {
+      message.success(data.message);
+      await modalApi.close();
+      emit('success');
+    }
+  } catch (error: any) {
+    message.error(error.message);
+  } finally {
+    modalApi.lock(false);
+  }
+}
+</script>
+
+<template>
+  <Modal
+    class="import-database-modal-wrapper"
+    :draggable="true"
+    :fullscreen-button="false"
+    :confirm-text="$t('ui.actionTitle.import')"
+  >
+    <UploadDragger
+      class="size-full"
+      name="file"
+      :show-upload-list="false"
+      :accept="props.accept"
+      :before-upload="() => false"
+      @change="onChangeHandle"
+      @reject="onAcceptError"
+    >
+      <p class="ant-upload-drag-icon text-center">
+        <FilePlus class="text-primary inline size-24" />
+      </p>
+      <span class="ant-upload-text">单击或将文件拖到此区域进行上传</span>
+    </UploadDragger>
+    <template v-if="$slots['prepend-footer']" #prepend-footer>
+      <slot name="prepend-footer"></slot>
+    </template>
+  </Modal>
+</template>
+
+<style scoped lang="scss">
+:deep(.ant-upload-btn) {
+  display: flex !important;
+  flex-direction: column;
+  justify-content: center;
+  padding: 0 !important;
+}
+
+:global(.import-database-modal-wrapper) {
+  height: 520px;
+}
+</style>

+ 81 - 0
apps/health-remedy/src/core/authentication/login.vue

@@ -0,0 +1,81 @@
+<script lang="ts" setup>
+import type { VbenFormSchema } from '@vben/common-ui';
+
+import type { Recordable } from '@vben-core/typings';
+
+import { computed, markRaw, ref, useTemplateRef } from 'vue';
+
+import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
+import { $t } from '@vben/locales';
+
+import { message } from 'ant-design-vue';
+
+import { useAuthStore } from '#/store';
+
+defineOptions({ name: 'Login' });
+
+const authStore = useAuthStore();
+
+const formSchema = computed((): VbenFormSchema[] => {
+  return [
+    {
+      component: 'VbenInput',
+      componentProps: {
+        placeholder: $t('authentication.usernameTip'),
+      },
+      fieldName: 'username',
+      label: $t('authentication.username'),
+      rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
+    },
+    {
+      component: 'VbenInputPassword',
+      componentProps: {
+        placeholder: $t('authentication.password'),
+      },
+      fieldName: 'password',
+      label: $t('authentication.password'),
+      rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
+    },
+    {
+      component: markRaw(SliderCaptcha),
+      fieldName: 'captcha',
+      rules: z.boolean().refine((value) => value, {
+        message: $t('authentication.verifyRequiredTip'),
+      }),
+    },
+  ];
+});
+
+const loginRef = useTemplateRef('loginRef');
+const loading = ref(false);
+async function handle(data: Recordable<any>) {
+  loading.value = true;
+  try {
+    await authStore.login(data as any);
+  } catch (error: any) {
+    message.error(error?.message ?? `登录失败`);
+    const formApi = loginRef.value?.getFormApi();
+    formApi?.setFieldValue('captcha', false, false);
+    formApi
+      ?.getFieldComponentRef<InstanceType<typeof SliderCaptcha>>('captcha')
+      ?.resume();
+  } finally {
+    loading.value = false;
+  }
+}
+</script>
+
+<template>
+  <AuthenticationLogin
+    ref="loginRef"
+    :form-schema="formSchema"
+    :loading="loading"
+    @submit="handle"
+    :show-remember-me="false"
+    :show-forget-password="false"
+    :show-code-login="false"
+    :show-qrcode-login="false"
+    :show-third-party-login="false"
+    :show-register="false"
+  />
+</template>

+ 7 - 0
apps/health-remedy/src/core/fallback/coming-soon.vue

@@ -0,0 +1,7 @@
+<script lang="ts" setup>
+import { Fallback } from '@vben/common-ui';
+</script>
+
+<template>
+  <Fallback status="coming-soon" />
+</template>

+ 9 - 0
apps/health-remedy/src/core/fallback/forbidden.vue

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

+ 9 - 0
apps/health-remedy/src/core/fallback/internal-error.vue

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

+ 11 - 0
apps/health-remedy/src/core/fallback/not-found.vue

@@ -0,0 +1,11 @@
+<script lang="ts" setup>
+import { Fallback } from '@vben/common-ui';
+import { useUserStore } from '@vben/stores';
+
+defineOptions({ name: 'Fallback404Demo' });
+const userStore = useUserStore();
+</script>
+
+<template>
+  <Fallback status="404" :home-path="userStore.userInfo?.homePath" />
+</template>

+ 9 - 0
apps/health-remedy/src/core/fallback/offline.vue

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

+ 26 - 0
apps/health-remedy/src/layouts/auth.vue

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

+ 79 - 0
apps/health-remedy/src/layouts/basic.vue

@@ -0,0 +1,79 @@
+<script lang="ts" setup>
+import type { NotificationItem } from '@vben/layouts';
+
+import { computed, ref, watch } from 'vue';
+
+import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
+import { useWatermark } from '@vben/hooks';
+import { BasicLayout, LockScreen, UserDropdown } from '@vben/layouts';
+import { preferences } from '@vben/preferences';
+import { useAccessStore, useUserStore } from '@vben/stores';
+
+import LoginForm from '#/core/authentication/login.vue';
+import { useAuthStore } from '#/store';
+
+const notifications = ref<NotificationItem[]>([]);
+
+const userStore = useUserStore();
+const authStore = useAuthStore();
+const accessStore = useAccessStore();
+const { destroyWatermark, updateWatermark } = useWatermark();
+
+computed(() => notifications.value.some((item) => !item.isRead));
+
+const avatar = computed(() => {
+  return userStore.userInfo?.avatar || preferences.app.defaultAvatar;
+});
+const text = computed(() => {
+  return (
+    userStore.userInfo?.realName ||
+    userStore.userInfo?.userName ||
+    userStore.userInfo?.username
+  );
+});
+
+async function handleLogout() {
+  await authStore.logout(false);
+}
+
+watch(
+  () => preferences.app.watermark,
+  async (enable) => {
+    if (enable) {
+      await updateWatermark({
+        content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
+      });
+    } else {
+      destroyWatermark();
+    }
+  },
+  {
+    immediate: true,
+  },
+);
+</script>
+
+<template>
+  <BasicLayout @clear-preferences-and-logout="handleLogout">
+    <template #user-dropdown>
+      <UserDropdown
+        :avatar
+        :text
+        :menus="[]"
+        :description="userStore.userInfo?.desc"
+        @logout="handleLogout"
+      />
+    </template>
+    <template #extra>
+      <AuthenticationLoginExpiredModal
+        v-model:open="accessStore.loginExpired"
+        :avatar
+      >
+        <LoginForm />
+      </AuthenticationLoginExpiredModal>
+    </template>
+    <template #lock-screen>
+      <LockScreen :avatar @to-login="handleLogout" />
+    </template>
+  </BasicLayout>
+</template>

+ 6 - 0
apps/health-remedy/src/layouts/index.ts

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

+ 102 - 0
apps/health-remedy/src/locales/index.ts

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

+ 5 - 0
apps/health-remedy/src/locales/langs/zh-CN/authentication.json

@@ -0,0 +1,5 @@
+{
+  "welcomeBack": "欢迎回来",
+  "pageTitle": "中医智能导诊管理系统",
+  "pageDesc": "智能、高效、管理"
+}

+ 30 - 0
apps/health-remedy/src/locales/langs/zh-CN/business.json

@@ -0,0 +1,30 @@
+{
+  "dept": {
+    "_": "科室",
+    "title": "科室维护",
+    "list": "科室列表",
+    "name": "科室名称",
+    "code": "科室编码",
+    "superior": "上级科室",
+    "description": "科室介绍",
+    "registerLink": "挂号连接"
+  },
+  "doctor": {
+    "_": "医生",
+    "title": "医生维护",
+    "list": "医生列表",
+    "name": "医生姓名",
+    "avatar": "头像",
+    "worker": "工号",
+    "titleOf": "头衔",
+    "titleOfClinical": "临床职称",
+    "titleOfTeach": "教学职称",
+    "description": "医生介绍",
+    "adeptAt": "擅长领域",
+    "registerLink": "挂号连接"
+  },
+  "organization": {
+    "_": "机构",
+    "name": "机构名称"
+  }
+}

+ 5 - 0
apps/health-remedy/src/locales/langs/zh-CN/common.json

@@ -0,0 +1,5 @@
+{
+  "status": "状态",
+  "enabled": "已启用",
+  "disabled": "已禁用"
+}

+ 9 - 0
apps/health-remedy/src/locales/langs/zh-CN/page.json

@@ -0,0 +1,9 @@
+{
+  "auth": {
+    "login": "登录",
+    "register": "注册",
+    "codeLogin": "验证码登录",
+    "qrcodeLogin": "二维码登录",
+    "forgetPassword": "忘记密码"
+  }
+}

+ 24 - 0
apps/health-remedy/src/locales/langs/zh-CN/system.json

@@ -0,0 +1,24 @@
+{
+  "title": "系统管理",
+  "role": {
+    "_": "角色",
+    "title": "角色管理",
+    "list": "角色列表",
+    "name": "角色名称",
+    "code": "角色标识",
+    "status": "状态",
+    "remark": "备注",
+    "setPermissions": "授权"
+  },
+  "user": {
+    "_": "账号",
+    "title": "账号管理",
+    "list": "账号列表",
+    "access": "账号",
+    "name": "姓名",
+    "worker": "工号",
+    "password": "密码",
+    "mobile": "手机号码",
+    "status": "状态"
+  }
+}

+ 12 - 0
apps/health-remedy/src/locales/langs/zh-CN/table.json

@@ -0,0 +1,12 @@
+{
+  "column": {
+    "seq": "序号",
+    "operation": "操作",
+    "createUser": "创建人",
+    "createTime": "创建时间",
+    "updateUser": "更新时间",
+    "updateTime": "更新人",
+    "lastUser": "最近一次修改人",
+    "lastTime": "最近一次修改时间"
+  }
+}

+ 31 - 0
apps/health-remedy/src/main.ts

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

+ 38 - 0
apps/health-remedy/src/preferences.ts

@@ -0,0 +1,38 @@
+import { defineOverridesPreferences } from '@vben/preferences';
+
+import dayjs from 'dayjs';
+
+/**
+ * @description 项目配置文件
+ * 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
+ * !!! 更改配置后请清空缓存,否则可能不生效
+ */
+export const overridesPreferences = defineOverridesPreferences({
+  // overrides
+  app: {
+    name: import.meta.env.VITE_APP_TITLE,
+    authPageLayout: 'panel-center',
+    layout: 'header-nav',
+    enablePreferences: false,
+  },
+  header: {
+    menuAlign: 'center',
+  },
+  logo: {
+    source: import.meta.env.VITE_APP_LOGO || '',
+  },
+  copyright: {
+    enable: true,
+    date: dayjs().format('YYYY'),
+    companyName: '杭州六智科技有限公司',
+    companySiteLink: '',
+  },
+  theme: {
+    mode: 'light',
+  },
+  widget: {
+    globalSearch: false,
+    refresh: false,
+    notification: false,
+  },
+});

+ 48 - 0
apps/health-remedy/src/router/access.ts

@@ -0,0 +1,48 @@
+import type {
+  ComponentRecordType,
+  GenerateMenuAndRoutesOptions,
+} from '@vben/types';
+
+import { generateAccessible } from '@vben/access';
+import { preferences } from '@vben/preferences';
+
+import { message } from 'ant-design-vue';
+
+import { getAccessMenuMethod } from '#/api';
+import { BasicLayout, IFrameView } from '#/layouts';
+import { $t } from '#/locales';
+
+const forbiddenComponent = () => import('#/core/fallback/forbidden.vue');
+
+async function generateAccess(options: GenerateMenuAndRoutesOptions) {
+  const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
+
+  const layoutMap: ComponentRecordType = {
+    BasicLayout,
+    IFrameView,
+  };
+
+  return await generateAccessible(preferences.app.accessMode, {
+    ...options,
+    fetchMenuListAsync: async (permissions) => {
+      let close;
+      const id = setTimeout(() => {
+        close = message.loading({
+          content: `${$t('common.loadingMenu')}...`,
+          duration: 0,
+        });
+      }, 500);
+      close = () => clearTimeout(id);
+      const menus = await getAccessMenuMethod(permissions).catch(() => []);
+      close?.();
+      return menus as any;
+    },
+    // 可以指定没有权限跳转403页面
+    forbiddenComponent,
+    // 如果 route.meta.menuVisibleWithForbidden = true
+    layoutMap,
+    pageMap,
+  });
+}
+
+export { generateAccess };

+ 143 - 0
apps/health-remedy/src/router/guard.ts

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

+ 37 - 0
apps/health-remedy/src/router/index.ts

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

+ 63 - 0
apps/health-remedy/src/router/routes/core.ts

@@ -0,0 +1,63 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { LOGIN_PATH } from '@vben/constants';
+import { preferences } from '@vben/preferences';
+
+import { $t } from '#/locales';
+
+const BasicLayout = () => import('#/layouts/basic.vue');
+const AuthPageLayout = () => import('#/layouts/auth.vue');
+/** 全局404页面 */
+const fallbackNotFoundRoute: RouteRecordRaw = {
+  component: () => import('#/core/fallback/not-found.vue'),
+  meta: {
+    hideInBreadcrumb: true,
+    hideInMenu: true,
+    hideInTab: true,
+    title: '404',
+  },
+  name: 'FallbackNotFound',
+  path: '/:path(.*)*',
+};
+
+/** 基本路由,这些路由是必须存在的 */
+const coreRoutes: RouteRecordRaw[] = [
+  /**
+   * 根路由
+   * 使用基础布局,作为所有页面的父级容器,子级就不必配置BasicLayout。
+   * 此路由必须存在,且不应修改
+   */
+  {
+    component: BasicLayout,
+    meta: {
+      hideInBreadcrumb: true,
+      title: 'Root',
+    },
+    name: 'Root',
+    path: '/',
+    redirect: preferences.app.defaultHomePath || void 0,
+    children: [],
+  },
+  {
+    component: AuthPageLayout,
+    meta: {
+      hideInTab: true,
+      title: 'Authentication',
+    },
+    name: 'Authentication',
+    path: '/auth',
+    redirect: LOGIN_PATH,
+    children: [
+      {
+        name: 'Login',
+        path: 'login',
+        component: () => import('#/core/authentication/login.vue'),
+        meta: {
+          title: $t('page.auth.login'),
+        },
+      },
+    ],
+  },
+];
+
+export { coreRoutes, fallbackNotFoundRoute };

+ 40 - 0
apps/health-remedy/src/router/routes/index.ts

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

+ 77 - 0
apps/health-remedy/src/store/auth.ts

@@ -0,0 +1,77 @@
+import { computed, unref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { DEFAULT_PATH, LOGIN_PATH } from '@vben/constants';
+import { $t } from '@vben/locales';
+import { preferences } from '@vben/preferences';
+import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
+
+import { useRequest } from '@six/request';
+import { message, notification } from 'ant-design-vue';
+import { defineStore } from 'pinia';
+
+import { getUserInfoMethod, loginMethod } from '#/api/method/access';
+
+export const useAuthStore = defineStore('auth', () => {
+  const accessStore = useAccessStore();
+  const userStore = useUserStore();
+  const router = useRouter();
+
+  const login = useRequest(loginMethod, {
+    immediate: false,
+  }).onSuccess(async ({ data }) => {
+    const userInfo = await userinfo.send(data.accessToken);
+
+    await router.push(
+      userInfo.homePath || preferences.app.defaultHomePath || DEFAULT_PATH,
+    );
+    if (userInfo.realName) {
+      notification.success({
+        description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
+        duration: 3,
+        message: $t('authentication.loginSuccess'),
+      });
+    } else {
+      message.success($t('authentication.loginSuccessDesc'));
+    }
+  });
+
+  const logout = {
+    async send(redirect: boolean = true) {
+      resetAllStores();
+      accessStore.setLoginExpired(false);
+      // 回登录页带上当前路由地址
+      await router.replace({
+        path: LOGIN_PATH,
+        query: redirect
+          ? { redirect: encodeURIComponent(router.currentRoute.value.fullPath) }
+          : {},
+      });
+    },
+  };
+
+  const userinfo = useRequest(getUserInfoMethod, {
+    immediate: false,
+  }).onSuccess(({ data }) => {
+    userStore.setUserInfo(data);
+  });
+
+  function $reset() {
+    login.abort();
+    userinfo.abort();
+    accessStore.$reset();
+    userStore.$reset();
+  }
+
+  const loading = computed(() =>
+    [login, userinfo].some((method) => unref(method.loading)),
+  );
+
+  return {
+    $reset,
+    loading,
+    login: login.send,
+    logout: logout.send,
+    getUserInfo: userinfo.send,
+  };
+});

+ 1 - 0
apps/health-remedy/src/store/index.ts

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

+ 124 - 0
apps/health-remedy/src/views/system/role/data.ts

@@ -0,0 +1,124 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api';
+
+import { $t } from '#/locales';
+
+export function useRoleSearchFormSchema(): VbenFormSchema[] {
+  return [
+    { component: 'Input', fieldName: 'name', label: $t('system.role.name') },
+    {
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: [
+          { label: $t('common.enabled'), value: 1 },
+          { label: $t('common.disabled'), value: 0 },
+        ],
+      },
+      fieldName: 'status',
+      label: $t('system.role.status'),
+    },
+  ];
+}
+
+export function useRoleTableColumns<T = SystemModel.Role>(
+  onActionClick: OnActionClickFn<T>,
+  onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
+): VxeTableGridOptions['columns'] {
+  return [
+    {
+      field: 'name',
+      title: $t('system.role.name'),
+      width: 200,
+    },
+    {
+      field: 'code',
+      title: $t('system.role.code'),
+      width: 200,
+    },
+    {
+      cellRender: {
+        attrs: { beforeChange: onStatusChange },
+        // props: { accessRole: '超级管理员' },
+        name: onStatusChange ? 'CellSwitch' : 'CellTag',
+      },
+      field: 'status',
+      title: $t('system.role.status'),
+      width: 100,
+    },
+    {
+      field: 'remark',
+      minWidth: 100,
+      title: $t('system.role.remark'),
+    },
+    {
+      field: 'lastTime',
+      title: $t('table.column.lastTime'),
+    },
+    {
+      field: 'lastUser',
+      title: $t('table.column.lastUser'),
+    },
+    {
+      field: 'createUser',
+      title: $t('table.column.createUser'),
+    },
+    {
+      field: 'createTime',
+      title: $t('table.column.createTime'),
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.role.name'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('table.column.operation'),
+      width: 130,
+    },
+  ];
+}
+
+export function useRoleFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.role.name'),
+      rules: 'required',
+    },
+    {
+      component: 'RadioGroup',
+      componentProps: {
+        buttonStyle: 'solid',
+        options: [
+          { label: $t('common.enabled'), value: 1 },
+          { label: $t('common.disabled'), value: 0 },
+        ],
+        optionType: 'button',
+      },
+      defaultValue: 1,
+      fieldName: 'status',
+      label: $t('system.role.status'),
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'remark',
+      label: $t('system.role.remark'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'permissions',
+      formItemClass: 'items-start',
+      label: $t('system.role.setPermissions'),
+      modelPropName: 'modelValue',
+    },
+  ];
+}

+ 156 - 0
apps/health-remedy/src/views/system/role/list.vue

@@ -0,0 +1,156 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api';
+
+import { Page, useVbenDrawer } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+  deleteRoleMethod,
+  listRolesMethod,
+  updateRoleStatusMethod,
+} from '#/api';
+import { $t } from '#/locales';
+
+import { useRoleSearchFormSchema, useRoleTableColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormDrawer, formDrawerApi] = useVbenDrawer({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
+    schema: useRoleSearchFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useRoleTableColumns(onActionClick, onStatusChange),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listRolesMethod(page.currentPage, page.pageSize, formValues);
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    toolbarConfig: {
+      custom: true,
+      export: false,
+      refresh: true,
+      search: true,
+      zoom: true,
+    },
+  } as VxeTableGridOptions<SystemModel.Role>,
+});
+
+function onActionClick(e: OnActionClickParams<SystemModel.Role>) {
+  switch (e.code) {
+    case 'delete': {
+      onDeleteHandle(e.row);
+      break;
+    }
+    case 'edit': {
+      onEditHandle(e.row);
+      break;
+    }
+  }
+}
+
+/**
+ * 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
+ * @param content 提示内容
+ * @param title 提示标题
+ */
+function confirm(content: string, title: string) {
+  return new Promise((reslove, reject) => {
+    Modal.confirm({
+      content,
+      onCancel() {
+        reject(new Error('已取消'));
+      },
+      onOk() {
+        reslove(true);
+      },
+      title,
+    });
+  });
+}
+
+/**
+ * 状态开关即将改变
+ * @param newStatus 期望改变的状态值
+ * @param row 行数据
+ * @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
+ */
+async function onStatusChange(newStatus: 0 | 1, row: SystemModel.Role) {
+  const status: Recordable<string> = {
+    0: '禁用',
+    1: '启用',
+  };
+  try {
+    await confirm(
+      `你要将${row.name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
+      `切换状态`,
+    );
+    await updateRoleStatusMethod(row.id, { status: newStatus });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function onRefresh() {
+  gridApi.query();
+}
+
+function onEditHandle(row?: SystemModel.Role) {
+  formDrawerApi.setData(row ?? {}).open();
+}
+
+async function onDeleteHandle(row: SystemModel.Role) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  try {
+    await deleteRoleMethod(row);
+    message.success({
+      content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+      key: 'action_process_msg',
+    });
+    onRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormDrawer @success="onRefresh" />
+    <Grid :table-title="$t('system.role.list')">
+      <template #toolbar-tools>
+        <Button type="primary" @click="onEditHandle()">
+          <Plus class="size-5" />
+          {{ $t('ui.actionTitle.create', [$t('system.role._')]) }}
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 133 - 0
apps/health-remedy/src/views/system/role/modules/form.vue

@@ -0,0 +1,133 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import type { SystemModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenDrawer, VbenTree } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { useRequest } from '@six/request';
+import { Spin } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { editRoleMethod, getMenusMethod } from '#/api';
+import { $t } from '#/locales';
+
+import { useRoleFormSchema } from '../data';
+
+const emits = defineEmits(['success']);
+
+const {
+  loading,
+  data: menus,
+  send: loadMenus,
+} = useRequest(getMenusMethod, { immediate: false, initialData: [] });
+
+const edit = useRequest(editRoleMethod, { immediate: false }).onSuccess(() => {
+  emits('success');
+});
+
+const formData = ref<SystemModel.Role>();
+const getTitle = computed(() => {
+  return formData.value?.id
+    ? $t('ui.actionTitle.edit', [$t('system.role.name')])
+    : $t('ui.actionTitle.create', [$t('system.role.name')]);
+});
+
+const [Form, formApi] = useVbenForm({
+  schema: useRoleFormSchema(),
+  showDefaultActions: false,
+});
+
+const [Drawer, drawerApi] = useVbenDrawer({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+    drawerApi.lock();
+    const data = await formApi.getValues();
+    try {
+      await edit.send({ ...formData.value, ...data });
+      await drawerApi.close();
+    } finally {
+      drawerApi.unlock();
+    }
+  },
+
+  async onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = drawerApi.getData<SystemModel.Role>();
+      await formApi.resetForm();
+      if (data) {
+        formData.value = data;
+        await formApi.setValues(formData.value);
+      }
+      if (menus.value.length === 0) {
+        await loadMenus();
+        if (formData.value?.permissions?.length)
+          await formApi.setValues(formData.value);
+      }
+    }
+  },
+});
+
+function getNodeClass(node: Recordable<any>) {
+  const classes: string[] = [];
+  if (node.value?.type === 'button') {
+    classes.push('inline-flex');
+    if (node.index % 3 >= 1) {
+      classes.push('!pl-0');
+    }
+  }
+
+  return classes.join(' ');
+}
+</script>
+<template>
+  <Drawer :title="getTitle">
+    <Form>
+      <template #permissions="slotProps">
+        <Spin :spinning="loading" wrapper-class-name="w-full menus-loading">
+          <VbenTree
+            :tree-data="menus"
+            multiple
+            bordered
+            :default-expanded-level="2"
+            :get-node-class="getNodeClass"
+            v-bind="slotProps"
+            value-field="id"
+            label-field="meta.title"
+            icon-field="meta.icon"
+          >
+            <template #node="{ value }">
+              <IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
+              {{ $t(value.meta.title) }}
+            </template>
+          </VbenTree>
+        </Spin>
+      </template>
+    </Form>
+  </Drawer>
+</template>
+<style lang="css" scoped>
+.menus-loading {
+  min-height: 24px;
+}
+
+:deep(.ant-tree-title) {
+  .tree-actions {
+    display: none;
+    margin-left: 20px;
+  }
+}
+
+:deep(.ant-tree-title:hover) {
+  .tree-actions {
+    display: flex;
+    flex: auto;
+    justify-content: flex-end;
+    margin-left: 20px;
+  }
+}
+</style>

+ 140 - 0
apps/health-remedy/src/views/system/user/data.ts

@@ -0,0 +1,140 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api/method/system';
+
+import { z } from '#/adapter/form';
+import { optionsRoleMethod } from '#/api/method/system';
+import { $t } from '#/locales';
+
+export function useUserSearchFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'access',
+      label: $t('system.user.access'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.user.name'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'worker',
+      label: $t('system.user.worker'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'mobile',
+      label: $t('system.user.mobile'),
+    },
+  ];
+}
+
+export function useUserTableColumns<T = SystemModel.User>(
+  onActionClick?: OnActionClickFn<T>,
+): VxeTableGridOptions<T>['columns'] {
+  return [
+    { type: 'seq', title: $t('table.column.seq'), width: 50 },
+    {
+      field: 'access',
+      title: $t('system.user.access'),
+      minWidth: 100,
+    },
+    {
+      field: 'name',
+      title: $t('system.user.name'),
+      minWidth: 100,
+    },
+    {
+      field: 'worker',
+      title: $t('system.user.worker'),
+      minWidth: 100,
+    },
+    {
+      field: 'mobile',
+      title: $t('system.user.mobile'),
+      minWidth: 100,
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.user._'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('table.column.operation'),
+      width: 130,
+    },
+  ];
+}
+
+export function useUserFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'access',
+      label: $t('system.user.access'),
+      rules: 'required',
+    },
+    {
+      component: 'InputPassword',
+      componentProps: {
+        placeholder: '请输入密码',
+      },
+      fieldName: 'password',
+      label: '密码',
+      rules: z
+        .string()
+        .refine(
+          (value) =>
+            /^(?=.*[A-Z])(?=.*[a-z]|.*\d)|(?=.*[a-z])(?=.*[A-Z]|.*\d)|(?=.*\d)(?=.*[A-Z]|.*[a-z]).{8,18}$/.test(
+              value,
+            ),
+          { message: '请输入8-18位数字、大写字母、小写字母最少两种组合的密码' },
+        ),
+    },
+    {
+      component: 'ApiSelect',
+      componentProps: {
+        allowClear: true,
+        api: optionsRoleMethod,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+
+        mode: 'multiple',
+      },
+      fieldName: 'roles',
+      label: $t('system.role._'),
+      rules: 'selectRequired',
+    },
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.user.name'),
+      rules: 'required',
+    },
+    {
+      component: 'Input',
+      fieldName: 'worker',
+      label: $t('system.user.worker'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'mobile',
+      label: $t('system.user.mobile'),
+      rules: z
+        .string()
+        .length(11, $t('ui.formRules.length', [$t('system.user.mobile'), 11])),
+    },
+  ];
+}

+ 106 - 0
apps/health-remedy/src/views/system/user/list.vue

@@ -0,0 +1,106 @@
+<script lang="ts" setup>
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemModel } from '#/api';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteUserMethod, listUsersMethod } from '#/api';
+import { $t } from '#/locales';
+
+import { useUserSearchFormSchema, useUserTableColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    schema: useUserSearchFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useUserTableColumns(onActionClick),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query({ page }, formValues) {
+          return listUsersMethod(page.currentPage, page.pageSize, formValues);
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    toolbarConfig: {
+      custom: true,
+      export: false,
+      refresh: true,
+      search: true,
+      zoom: true,
+    },
+  } as VxeTableGridOptions<SystemModel.User>,
+});
+
+function onActionClick(e: OnActionClickParams<SystemModel.User>) {
+  switch (e.code) {
+    case 'delete': {
+      onDeleteHandle(e.row);
+      break;
+    }
+    case 'edit': {
+      onEditHandle(e.row);
+      break;
+    }
+  }
+}
+
+function onRefresh() {
+  gridApi.query();
+}
+
+function onEditHandle(row?: SystemModel.User) {
+  formModalApi.setData(row ?? {}).open();
+}
+
+async function onDeleteHandle(row: SystemModel.User) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  try {
+    await deleteUserMethod(row);
+    message.success({
+      content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+      key: 'action_process_msg',
+    });
+    onRefresh();
+  } finally {
+    hideLoading();
+  }
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="onRefresh" />
+    <Grid :table-title="$t('system.user.list')">
+      <template #toolbar-tools>
+        <Button type="primary" @click="onEditHandle()">
+          <Plus class="size-5" />
+          {{ $t('ui.actionTitle.create', [$t('system.user._')]) }}
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 90 - 0
apps/health-remedy/src/views/system/user/modules/form.vue

@@ -0,0 +1,90 @@
+<script lang="ts" setup>
+import type { SystemModel } from '#/api';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { useRequest } from '@six/request';
+import { Button } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { editUserMethod } from '#/api';
+import { $t } from '#/locales';
+
+import { useUserFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+
+const edit = useRequest(editUserMethod, { immediate: false }).onSuccess(() => {
+  emit('success');
+});
+
+const formData = ref<SystemModel.User>();
+const getTitle = computed(() => {
+  return formData.value?.id
+    ? $t('ui.actionTitle.edit', [$t('system.user._')])
+    : $t('ui.actionTitle.create', [$t('system.user._')]);
+});
+
+const [Form, formApi] = useVbenForm({
+  // layout: 'vertical',
+  schema: useUserFormSchema(),
+  showDefaultActions: false,
+});
+
+function resetForm() {
+  formApi.resetForm();
+  formApi.setValues(formData.value || {});
+}
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (valid) {
+      modalApi.lock();
+      const data = await formApi.getValues();
+      try {
+        await edit.send({ ...formData.value, ...data });
+        await modalApi.close();
+      } finally {
+        modalApi.lock(false);
+      }
+    }
+  },
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = modalApi.getData<SystemModel.User>();
+      if (data) {
+        if (data.id) {
+          formApi.setState((prev) => {
+            const schema =
+              prev.schema?.filter(
+                (schema) => schema.fieldName !== 'password',
+              ) ?? [];
+            return { schema };
+          });
+        }
+        formData.value = data;
+        formData.value.roles = data.roles?.map((role) =>
+          typeof role === 'string' ? role : role.id,
+        );
+        formApi.setValues(formData.value);
+      }
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle">
+    <Form class="mx-4" />
+    <template #prepend-footer>
+      <div class="flex-auto">
+        <Button type="primary" danger @click="resetForm">
+          {{ $t('common.reset') }}
+        </Button>
+      </div>
+    </template>
+  </Modal>
+</template>

+ 1 - 0
apps/health-remedy/tailwind.config.mjs

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

+ 12 - 0
apps/health-remedy/tsconfig.json

@@ -0,0 +1,12 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@vben/tsconfig/web-app.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "#/*": ["./src/*"]
+    }
+  },
+  "references": [{ "path": "./tsconfig.node.json" }],
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 10 - 0
apps/health-remedy/tsconfig.node.json

@@ -0,0 +1,10 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@vben/tsconfig/node.json",
+  "compilerOptions": {
+    "composite": true,
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "noEmit": false
+  },
+  "include": ["vite.config.mts"]
+}

+ 25 - 0
apps/health-remedy/vite.config.mts

@@ -0,0 +1,25 @@
+import { defineConfig } from '@vben/vite-config';
+
+export default defineConfig(async () => {
+  return {
+    application: {},
+    vite: {
+      server: {
+        proxy: {
+          '/dz': {
+            changeOrigin: true,
+            target: `https://wx.hzliuzhi.com:4433`,
+            ws: true,
+          },
+          '/api': {
+            changeOrigin: true,
+            rewrite: (path) => path.replace(/^\/api/, ''),
+            // mock代理目标地址
+            target: 'http://localhost:5320/api',
+            ws: true,
+          },
+        },
+      },
+    },
+  };
+});

+ 2 - 0
package.json

@@ -27,6 +27,7 @@
   "scripts": {
     "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 turbo build",
     "build:hg": "pnpm run build --filter=@six/hospital-guide",
+    "build:hr": "pnpm run build --filter=@six/health-remedy",
     "changeset": "pnpm exec changeset",
     "check": "pnpm run check:circular && pnpm run check:dep && pnpm run check:type && pnpm check:cspell",
     "check:circular": "vsh check-circular",
@@ -37,6 +38,7 @@
     "commit": "czg",
     "dev": "turbo-run dev",
     "dev:hg": "pnpm -F @six/hospital-guide run dev",
+    "dev:hr": "pnpm -F @six/health-remedy run dev",
     "format": "vsh lint --format",
     "lint": "vsh lint",
     "postinstall": "pnpm -r run stub --if-present",

+ 76 - 0
pnpm-lock.yaml

@@ -593,6 +593,82 @@ importers:
         specifier: 'catalog:'
         version: 2.2.10(typescript@5.8.3)
 
+  apps/health-remedy:
+    dependencies:
+      '@six/request':
+        specifier: workspace:*
+        version: link:../../packages/request
+      '@vben-core/menu-ui':
+        specifier: workspace:*
+        version: link:../../packages/@core/ui-kit/menu-ui
+      '@vben/access':
+        specifier: workspace:*
+        version: link:../../packages/effects/access
+      '@vben/common-ui':
+        specifier: workspace:*
+        version: link:../../packages/effects/common-ui
+      '@vben/constants':
+        specifier: workspace:*
+        version: link:../../packages/constants
+      '@vben/hooks':
+        specifier: workspace:*
+        version: link:../../packages/effects/hooks
+      '@vben/icons':
+        specifier: workspace:*
+        version: link:../../packages/icons
+      '@vben/layouts':
+        specifier: workspace:*
+        version: link:../../packages/effects/layouts
+      '@vben/locales':
+        specifier: workspace:*
+        version: link:../../packages/locales
+      '@vben/plugins':
+        specifier: workspace:*
+        version: link:../../packages/effects/plugins
+      '@vben/preferences':
+        specifier: workspace:*
+        version: link:../../packages/preferences
+      '@vben/stores':
+        specifier: workspace:*
+        version: link:../../packages/stores
+      '@vben/styles':
+        specifier: workspace:*
+        version: link:../../packages/styles
+      '@vben/types':
+        specifier: workspace:*
+        version: link:../../packages/types
+      '@vben/utils':
+        specifier: workspace:*
+        version: link:../../packages/utils
+      '@vueuse/core':
+        specifier: 'catalog:'
+        version: 13.9.0(vue@3.5.17(typescript@5.8.3))
+      '@vueuse/router':
+        specifier: 'catalog:'
+        version: 13.9.0(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))
+      alova:
+        specifier: 'catalog:'
+        version: 3.3.4
+      ant-design-vue:
+        specifier: 'catalog:'
+        version: 4.2.6(vue@3.5.17(typescript@5.8.3))
+      dayjs:
+        specifier: 'catalog:'
+        version: 1.11.13
+      pinia:
+        specifier: ^3.0.3
+        version: 3.0.3(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3))
+      vue:
+        specifier: ^3.5.17
+        version: 3.5.17(typescript@5.8.3)
+      vue-router:
+        specifier: 'catalog:'
+        version: 4.5.1(vue@3.5.17(typescript@5.8.3))
+    devDependencies:
+      '@vben-core/typings':
+        specifier: workspace:*
+        version: link:../../packages/@core/base/typings
+
   apps/hospital-guide:
     dependencies:
       '@six/request':

+ 4 - 0
vben-admin.code-workspace

@@ -1,5 +1,9 @@
 {
   "folders": [
+    {
+      "name": "@six/health-remedy",
+      "path": "apps/health-remedy",
+    },
     {
       "name": "@six/hospital-guide",
       "path": "apps/hospital-guide",