Przeglądaj źródła

角色 & 用户

cc12458 1 rok temu
rodzic
commit
f031293981

+ 5 - 0
@types/components.d.ts

@@ -8,6 +8,7 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AAvatar: typeof import('ant-design-vue/es')['Avatar']
+    ABadge: typeof import('ant-design-vue/es')['Badge']
     AButton: typeof import('ant-design-vue/es')['Button']
     ACard: typeof import('ant-design-vue/es')['Card']
     ACollapse: typeof import('ant-design-vue/es')['Collapse']
@@ -25,7 +26,11 @@ declare module 'vue' {
     ASpin: typeof import('ant-design-vue/es')['Spin']
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    RoleEdit: typeof import('./../src/components/RoleEdit.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    UserEdit: typeof import('./../src/components/UserEdit.vue')['default']
+    UserPassword: typeof import('./../src/components/UserPassword.vue')['default']
+    UserPreview: typeof import('./../src/components/UserPreview.vue')['default']
   }
 }

+ 1 - 0
@types/typed-router.d.ts

@@ -20,6 +20,7 @@ declare module 'vue-router/auto-routes' {
   export interface RouteNamedMap {
     '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
     '//system/role': RouteRecordInfo<'//system/role', '/system/role', Record<never, never>, Record<never, never>>,
+    '//system/user': RouteRecordInfo<'//system/user', '/system/user', Record<never, never>, Record<never, never>>,
     '/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
   }
 }

+ 4 - 1
package.json

@@ -24,7 +24,9 @@
     "pinia-plugin-persistedstate": "^3.2.1",
     "vue": "^3.4.29",
     "vue-router": "^4.3.3",
-    "vue-virtual-scroller": "2.0.0-beta.8"
+    "vue-virtual-scroller": "2.0.0-beta.8",
+    "vxe-pc-ui": "^4.0.91",
+    "vxe-table": "^4.7.62"
   },
   "devDependencies": {
     "@alova/mock": "^2.0.4",
@@ -52,6 +54,7 @@
     "unplugin-vue-components": "^0.27.0",
     "unplugin-vue-router": "^0.10.2",
     "vite": "^5.3.1",
+    "vite-plugin-lazy-import": "^1.0.7",
     "vite-plugin-vue-devtools": "^7.3.1",
     "vue-tsc": "^2.0.21"
   }

+ 58 - 9
pnpm-lock.yaml

@@ -41,6 +41,12 @@ dependencies:
   vue-virtual-scroller:
     specifier: 2.0.0-beta.8
     version: 2.0.0-beta.8(vue@3.4.30)
+  vxe-pc-ui:
+    specifier: ^4.0.91
+    version: 4.0.91
+  vxe-table:
+    specifier: ^4.7.62
+    version: 4.7.62
 
 devDependencies:
   '@alova/mock':
@@ -118,6 +124,9 @@ devDependencies:
   vite:
     specifier: ^5.3.1
     version: 5.3.1(@types/node@20.14.8)(sass@1.77.6)
+  vite-plugin-lazy-import:
+    specifier: ^1.0.7
+    version: 1.0.7
   vite-plugin-vue-devtools:
     specifier: ^7.3.1
     version: 7.3.4(vite@5.3.1)(vue@3.4.30)
@@ -953,7 +962,7 @@ packages:
     resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==}
     dev: true
 
-  /@rollup/pluginutils@5.1.0:
+  /@rollup/pluginutils@5.1.0(rollup@4.18.0):
     resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
     engines: {node: '>=14.0.0'}
     peerDependencies:
@@ -965,6 +974,7 @@ packages:
       '@types/estree': 1.0.5
       estree-walker: 2.0.2
       picomatch: 2.3.1
+      rollup: 4.18.0
     dev: true
 
   /@rollup/rollup-android-arm-eabi@4.18.0:
@@ -1289,7 +1299,7 @@ packages:
     hasBin: true
     dependencies:
       '@ampproject/remapping': 2.3.0
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       '@unocss/config': 0.61.0
       '@unocss/core': 0.61.0
       '@unocss/preset-uno': 0.61.0
@@ -1466,7 +1476,7 @@ packages:
       vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0
     dependencies:
       '@ampproject/remapping': 2.3.0
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       '@unocss/config': 0.61.0
       '@unocss/core': 0.61.0
       '@unocss/inspector': 0.61.0
@@ -1535,7 +1545,7 @@ packages:
         optional: true
     dependencies:
       '@babel/types': 7.25.2
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       '@vue/compiler-sfc': 3.4.36
       ast-kit: 1.0.1
       local-pkg: 0.5.0
@@ -1821,6 +1831,13 @@ packages:
       - '@vue/composition-api'
       - vue
 
+  /@vxe-ui/core@4.0.1:
+    resolution: {integrity: sha512-h4LsYw+6ApUHCH2AsAN/CJNG6aEzEGMHWsRhb3tWONlFl5VdCfGJIes/aqcOD57NTifP4KfTJvPBHfu87URksQ==}
+    dependencies:
+      dom-zindex: 1.0.4
+      xe-utils: 3.5.29
+    dev: false
+
   /acorn-jsx@5.3.2(acorn@8.12.0):
     resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
     peerDependencies:
@@ -2232,6 +2249,10 @@ packages:
     resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==}
     dev: false
 
+  /dom-zindex@1.0.4:
+    resolution: {integrity: sha512-PNk7u71TJ1C9Lwjjp5nNuQcVWuECFMmr9kZAwi2UbgWUM7jXdTCe4O4x5bhLUa07jpcZUVA5Du3ho7/FXzS9Ng==}
+    dev: false
+
   /duplexer@0.1.2:
     resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
     dev: true
@@ -2253,6 +2274,10 @@ packages:
     resolution: {integrity: sha512-l0uy0kAoo6toCgVOYaAayqtPa2a1L15efxUMEnQebKwLQX2X0OpS6wMMQdc4juJXmxd9i40DuaUHq+mjIya9TQ==}
     dev: true
 
+  /es-module-lexer@1.5.4:
+    resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==}
+    dev: true
+
   /esbuild@0.21.5:
     resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
     engines: {node: '>=12'}
@@ -3623,7 +3648,7 @@ packages:
   /unimport@3.7.2:
     resolution: {integrity: sha512-91mxcZTadgXyj3lFWmrGT8GyoRHWuE5fqPOjg5RVtF6vj+OfM5G6WCzXjuYtSgELE5ggB34RY4oiCSEP8I3AHw==}
     dependencies:
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       acorn: 8.12.0
       escape-string-regexp: 5.0.0
       estree-walker: 3.0.3
@@ -3697,7 +3722,7 @@ packages:
         optional: true
     dependencies:
       '@antfu/utils': 0.7.8
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       '@vueuse/core': 10.11.0(vue@3.4.30)
       fast-glob: 3.3.2
       local-pkg: 0.5.0
@@ -3723,7 +3748,7 @@ packages:
         optional: true
     dependencies:
       '@antfu/utils': 0.7.8
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       chokidar: 3.6.0
       debug: 4.3.5
       fast-glob: 3.3.2
@@ -3747,7 +3772,7 @@ packages:
         optional: true
     dependencies:
       '@babel/types': 7.25.2
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       '@vue-macros/common': 1.12.2(vue@3.4.30)
       ast-walker-scope: 0.6.1
       chokidar: 3.6.0
@@ -3825,7 +3850,7 @@ packages:
         optional: true
     dependencies:
       '@antfu/utils': 0.7.8
-      '@rollup/pluginutils': 5.1.0
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
       debug: 4.3.5
       error-stack-parser-es: 0.1.4
       fs-extra: 11.2.0
@@ -3839,6 +3864,15 @@ packages:
       - supports-color
     dev: true
 
+  /vite-plugin-lazy-import@1.0.7:
+    resolution: {integrity: sha512-mE6oAObOb4wqso4AoUGi9cLjdR+4vay1RCaKJvziBuFPlziZl7J0aw2hsqRTokLVRx3bli0a0VyjMOwsNDv58A==}
+    dependencies:
+      '@rollup/pluginutils': 5.1.0(rollup@4.18.0)
+      es-module-lexer: 1.5.4
+      rollup: 4.18.0
+      xe-utils: 3.5.29
+    dev: true
+
   /vite-plugin-vue-devtools@7.3.4(vite@5.3.1)(vue@3.4.30):
     resolution: {integrity: sha512-5WKGIFldO9/E/J6d+x286ENieFUsexcg8Qgh/js3rEJtzipHzxiD47xMJVSBhl14n1E4jABIMuwmn1FYtpwm3w==}
     engines: {node: '>=v14.21.3'}
@@ -4031,6 +4065,18 @@ packages:
       '@vue/shared': 3.4.30
       typescript: 5.4.5
 
+  /vxe-pc-ui@4.0.91:
+    resolution: {integrity: sha512-DBqGd7xfwRJO6XEBKjWpd0zlEl2rqFNPoq3SRqYV2og54+D90cnE1vZrPds/G4dtZL1YbHu9y9uuReT8ZuEqBg==}
+    dependencies:
+      '@vxe-ui/core': 4.0.1
+    dev: false
+
+  /vxe-table@4.7.62:
+    resolution: {integrity: sha512-BP6NuU6zIJ7LBzWiVTWJUuGDsDBIhBSuLRzqr93c56Jhx5KtUXdebPZCnUDiDFgqHUY1JkRDoAdpySsYpU+lgg==}
+    dependencies:
+      vxe-pc-ui: 4.0.91
+    dev: false
+
   /warning@4.0.3:
     resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
     dependencies:
@@ -4063,6 +4109,9 @@ packages:
     resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
     dev: true
 
+  /xe-utils@3.5.29:
+    resolution: {integrity: sha512-9Ty3k/nHUptUzBZ/S+Z6umfpNWKU6ZFdGR2qHeynKRTjI/Tjd5ku/n6O1oKQCwtxQ3QzCl3NnD2LRy9SCTmnNw==}
+
   /xml-name-validator@4.0.0:
     resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
     engines: {node: '>=12'}

+ 57 - 0
src/components/RoleEdit.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import type { RoleModel }                           from '@/model/system.model';
+import { editRoleMethod, roleMethod }               from '@/request/api/system.api';
+import { useRequest }                               from 'alova/client';
+import { type VxeFormListeners, type VxeFormProps } from 'vxe-pc-ui';
+
+
+type FormModel = Partial<RoleModel>
+
+const defaultModel = {};
+
+const props = defineProps<{ data: FormModel }>();
+const emits = defineEmits<{
+  submit: [ data?: RoleModel ],
+}>();
+
+const { loading, send: load } = useRequest(roleMethod, { immediate: false, initialData: props.data ?? defaultModel })
+  .onSuccess(({ data }) => {
+    formProps.data = { ...data };
+  });
+const { loading: submitting, send: submit } = useRequest(editRoleMethod, { immediate: false }).onSuccess(({ data }) => {
+  emits('submit');
+});
+
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: { ...props.data },
+  items: [
+    { field: 'roleName', title: '角色名', span: 24, itemRender: { name: 'VxeInput' } },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {
+    roleName: [
+      { required: true, message: '请输入角色名' },
+    ],
+  },
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) { submit(data); },
+  reset() { formProps.data = { ...props.data }; },
+};
+
+onBeforeMount(() => {
+  if ( props.data?.roleId ) load(props.data);
+});
+</script>
+<template>
+  <vxe-form v-bind="formProps" v-on="formEmits" :loading>
+    <template #active>
+      <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>
+      <vxe-button type="reset" content="重置" :disabled="submitting"></vxe-button>
+    </template>
+  </vxe-form>
+</template>
+<style scoped lang="scss"></style>

+ 102 - 0
src/components/UserEdit.vue

@@ -0,0 +1,102 @@
+<script setup lang="ts">
+import { type UserModel }                                           from '@/model/system.model';
+import { branchMethod, editUserMethod, rolesAllMethod, userMethod } from '@/request/api/system.api';
+import { useRequest }                                               from 'alova/client';
+import { type VxeFormListeners, type VxeFormProps }                 from 'vxe-pc-ui';
+
+
+type FormModel = Partial<UserModel>
+
+const defaultModel = {};
+
+const props = defineProps<{ data: FormModel }>();
+const emits = defineEmits<{
+  submit: [ data?: UserModel ],
+}>();
+
+const { data: branch, loading: branchLoading } = useRequest(branchMethod);
+const { data: roles, loading: rolesLoading } = useRequest(rolesAllMethod);
+const { loading, send: load } = useRequest(userMethod, { immediate: false, initialData: props.data ?? defaultModel })
+  .onSuccess(({ data }) => {
+    formProps.data = { ...data };
+  });
+const { loading: submitting, send: submit } = useRequest(editUserMethod, { immediate: false }).onSuccess(({ data }) => {
+  emits('submit');
+});
+
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  titleAsterisk: true,
+  data: { ...props.data },
+  items: [
+    { field: 'userName', title: '系统账号', span: 24, itemRender: { name: 'VxeInput' } },
+    { field: 'password', title: '密码', span: 24, itemRender: { name: 'VxeInput', props: { type: 'password' } }, visible: !props.data?.userId },
+    {
+      field: 'roles', title: '角色', span: 24, itemRender: {
+        name: 'VxeSelect', props: {
+          multiple: true,
+          loading: computed(() => rolesLoading.value),
+          options: computed(() => roles.value),
+          optionProps: { label: 'roleName', value: 'roleId' },
+        },
+      },
+    },
+    { field: 'nickName', title: '姓名', span: 24, itemRender: { name: 'VxeInput' } },
+    {
+      field: 'deptId', title: '医院 / 科室', span: 24, itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => branchLoading.value),
+          options: computed(() => branch.value),
+          optionProps: { value: 'id' },
+        },
+      },
+    },
+    { field: '工号', title: '工号', span: 24, itemRender: { name: 'VxeInput' } },
+    { field: 'phonenumber', title: '手机号码', span: 24, itemRender: { name: 'VxeInput', props: { maxlength: 11 } } },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {
+    userName: [
+      { required: true, message: '请输入系统账号' },
+    ],
+    password: [
+      { required: !props.data?.userId, validator: 'ValidPassword' },
+    ],
+    roles: [
+      { required: true, message: '请选择角色' },
+    ],
+    nickName: [
+      { required: true, message: '请输入姓名' },
+    ],
+    deptId: [
+      { required: true, message: '请选择医院 / 科室' },
+    ],
+    工号: [
+      { required: true, message: '请输入工号' },
+    ],
+    phonenumber: [
+      { required: true, validator: 'ValidMobile' },
+    ],
+  },
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) { submit(data); },
+  reset() { formProps.data = { ...props.data }; },
+};
+
+onBeforeMount(() => {
+  if ( props.data?.userId ) load(props.data);
+});
+</script>
+<template>
+  <vxe-form v-bind="formProps" v-on="formEmits" :loading>
+    <template #active>
+      <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>
+      <vxe-button type="reset" content="重置" :disabled="submitting"></vxe-button>
+    </template>
+  </vxe-form>
+</template>
+<style scoped lang="scss"></style>

+ 59 - 0
src/components/UserPassword.vue

@@ -0,0 +1,59 @@
+<script setup lang="ts">
+import type { UserModel }                           from '@/model/system.model';
+import { editRoleMethod, updateUserPasswordMethod } from '@/request/api/system.api';
+import { useRequest }                               from 'alova/client';
+import type { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
+
+
+interface FormModel {
+  userId: string;
+  password?: string;
+  twice?: string;
+}
+
+const props = defineProps<{ data: UserModel }>();
+const emits = defineEmits<{
+  submit: [],
+}>();
+const { loading: submitting, send: submit } = useRequest(updateUserPasswordMethod, { immediate: false }).onSuccess(({ data }) => {
+  emits('submit');
+});
+const formProps = reactive<VxeFormProps<FormModel>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: { userId: props.data.userId },
+  items: [
+    { field: 'password', title: '新密码', span: 24, itemRender: { name: 'VxeInput', props: { type: 'password' } } },
+    { field: 'twice', title: '再次输入', span: 24, itemRender: { name: 'VxeInput', props: { type: 'password' } } },
+    { align: 'center', span: 24, slots: { default: 'active' } },
+  ],
+  rules: {
+    password: [
+      { required: true, validator: 'ValidPassword' },
+    ],
+    twice: [
+      { required: true, message: '请再次输入密码' },
+      { required: true, validator: 'ValidPassword' },
+      {
+        required: true, validator: (params) => {
+          if ( !params.itemValue || !params.data.password ) return;
+          if ( params.data.password !== params.itemValue ) return new Error(`两次输入的密码不一致`);
+        },
+      },
+    ],
+  },
+});
+const formEmits: VxeFormListeners<FormModel> = {
+  submit({ data }) { submit(data); },
+};
+</script>
+<template>
+  <vxe-form v-bind="formProps" v-on="formEmits">
+    <template #active>
+      <vxe-button type="submit" status="primary" content="提交" :loading="submitting"></vxe-button>
+    </template>
+  </vxe-form>
+</template>
+<style scoped lang="scss">
+</style>

+ 24 - 0
src/components/UserPreview.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import type { UserModel } from '@/model/system.model';
+import { userMethod }     from '@/request/api/system.api';
+import { useRequest }     from 'alova/client';
+
+
+const props = defineProps<{ data: UserModel }>();
+const { data, loading } = useRequest(() => userMethod(props.data), { initialData: props.data });
+
+</script>
+<template>
+  <a-spin :spinning="loading">
+    <a-descriptions bordered>
+      <a-descriptions-item label="系统账号">{{ data.userId }}</a-descriptions-item>
+      <a-descriptions-item label="角色" :span="2">{{ data.roles?.join('/') }}</a-descriptions-item>
+      <a-descriptions-item label="医院 / 科室" :span="2">{{ data['dept']?.deptName }}</a-descriptions-item>
+      <a-descriptions-item label="工号">{{ data.工号 }}</a-descriptions-item>
+      <a-descriptions-item label="姓名">{{ data.nickName }}</a-descriptions-item>
+      <a-descriptions-item label="手机号码">{{ data.phonenumber }}</a-descriptions-item>
+    </a-descriptions>
+  </a-spin>
+</template>
+<style scoped lang="scss">
+</style>

+ 5 - 0
src/libs/vxe/index.ts

@@ -0,0 +1,5 @@
+import { createVxe } from './plugin';
+import './validator'
+
+const vxe = createVxe();
+export default vxe;

+ 77 - 0
src/libs/vxe/plugin.ts

@@ -0,0 +1,77 @@
+import type { App } from 'vue';
+
+import {
+  VxeUI,
+  VxeButton,
+  VxeButtonGroup,
+  VxeForm,
+  VxeFormDesign,
+  VxeFormGather,
+  VxeFormItem,
+  VxeFormView,
+  VxeIcon,
+  VxeInput,
+  VxeLoading,
+  VxePager,
+  VxeRadio,
+  VxeRadioGroup,
+  VxeSelect,
+  VxeSwitch,
+  VxeTooltip,
+  VxeTreeSelect,
+  VxeTree,
+
+  VxeModal,
+  VxeDrawer,
+}           from 'vxe-pc-ui';
+
+import zhCN from 'vxe-pc-ui/lib/language/zh-CN';
+
+import { VxeColgroup, VxeColumn, VxeGrid, VxeTable, VxeToolbar } from 'vxe-table';
+
+
+function LazyVxeUIForForm(app: App) {
+  app.use(VxeForm);
+  app.use(VxeFormDesign);
+  app.use(VxeFormGather);
+  app.use(VxeFormItem);
+  app.use(VxeFormView);
+  app.use(VxeInput);
+  app.use(VxeRadio);
+  app.use(VxeSwitch);
+  app.use(VxeSelect);
+  app.use(VxeTreeSelect);
+  app.use(VxeTree);
+  app.use(VxeRadioGroup);
+
+
+  app.use(VxeButton);
+  app.use(VxeButtonGroup);
+  app.use(VxeIcon);
+  app.use(VxeLoading);
+  // 分页
+  app.use(VxePager);
+
+  app.use(VxeModal);
+  app.use(VxeDrawer);
+  app.use(VxeTooltip);
+}
+
+function LazyVxeUIForTable(app: App) {
+  app.use(VxeTable);
+  app.use(VxeColumn);
+  app.use(VxeColgroup);
+  app.use(VxeGrid);
+  app.use(VxeToolbar);
+}
+
+export function createVxe(options?: Record<string, any>) {
+  VxeUI.setI18n('zh-CN', zhCN);
+  VxeUI.setLanguage('zh-CN');
+  return {
+    install: (app: App) => {
+      LazyVxeUIForForm(app);
+      LazyVxeUIForTable(app);
+    },
+  };
+}

+ 22 - 0
src/libs/vxe/validator.ts

@@ -0,0 +1,22 @@
+import { VxeUI } from 'vxe-pc-ui';
+
+// 手机号校验
+VxeUI.validators.add('ValidMobile', {
+  itemValidatorMethod({ itemValue }) {
+    if ( !itemValue ) return new Error(`请输入手机号码`);
+    if ( !/^1[3-9]\d{9}$/.test(itemValue) ) return new Error('手机号码格式不正确');
+  },
+});
+VxeUI.validators.add('ValidPassword', {
+  itemValidatorMethod({ itemValue }) {
+    if ( !itemValue ) return new Error(`请输入密码`);
+    if ( !(
+      itemValue.length > 8 &&
+      /\d/.test(itemValue) &&
+      /[a-zA-Z]/.test(itemValue)
+    ) ) {
+      return new Error('密码格式不正确(由数字、字母组成,最少8位)');
+    }
+
+  },
+});

+ 3 - 0
src/main.ts

@@ -1,5 +1,7 @@
 import 'virtual:uno.css';
 import '@/themes/index.scss';
+
+import vxe    from '@/libs/vxe';
 import router from '@/router';
 import pinia  from '@/stores';
 
@@ -10,6 +12,7 @@ import App from './App.vue';
 
 const app = createApp(App);
 
+app.use(vxe);
 app.use(pinia);
 app.use(router);
 

+ 7 - 2
src/model/index.ts

@@ -1,2 +1,7 @@
-export * from './account.model'
-export * from './people.model'
+export * from './account.model';
+export * from './people.model';
+
+
+export type List<T> = { total: number; data: T[] };
+
+export type Tree<T> = ( T & { children?: T[], isLeaf?: boolean } )[]

+ 5 - 0
src/model/options.ts

@@ -0,0 +1,5 @@
+export const statusOptions = [
+  { label: '全部', value: void 0 },
+  { label: '已启用', value: '0' },
+  { label: '未启用', value: '1' },
+];

+ 27 - 0
src/model/system.model.ts

@@ -0,0 +1,27 @@
+export interface RoleQuery {
+  roleName?: string;
+  status?: RoleModel['status'];
+}
+
+export interface RoleModel {
+  roleId: string;
+  roleName: string;
+  status: '0' | '1';
+
+  createTime: string;
+  createBy: string;
+  updateTime: string;
+  updateBy: string;
+}
+
+export interface UserModel {
+  userId: string;
+  userName: string;
+  phonenumber?: string;
+
+  roles?: any[];
+
+  工号?: string;
+}
+
+export type UserQuery = Partial<UserModel> & { branch?: string };

+ 230 - 0
src/pages/index/system/role.vue

@@ -0,0 +1,230 @@
+<script setup lang="ts">
+import RoleEdit          from '@/components/RoleEdit.vue';
+import { statusOptions } from '@/model/options';
+
+import type { RoleModel, RoleQuery } from '@/model/system.model';
+
+import { deleteRoleMethod, rolesMethod, updateRoleStatusMethod } from '@/request/api/system.api';
+import { usePagination }                                         from 'alova/client';
+import { notification }                                          from 'ant-design-vue';
+
+import {
+  type VxeFormListeners,
+  type VxeFormProps,
+  type VxeGridInstance,
+  type VxeGridListeners,
+  type VxeGridProps,
+  VxeUI,
+} from 'vxe-pc-ui';
+
+
+const model = shallowRef<RoleQuery>();
+const searchFormProps = reactive<VxeFormProps<RoleQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {
+    roleName: '',
+    status: void 0,
+  },
+  items: [
+    { field: 'roleName', title: '角色名称', span: 6, itemRender: { name: 'VxeInput' } },
+    {
+      field: 'status', title: '是否启用', span: 6, itemRender: {
+        name: 'VxeSelect',
+        options: statusOptions,
+      },
+    },
+    {
+      span: 6, itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '清空' },
+        ],
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<RoleQuery> = {
+  submit({ data }) { model.value = { ...data }; },
+  reset({ data }) { model.value = { ...data }; },
+};
+
+const gridRef = ref<VxeGridInstance<RoleModel>>();
+const gridOptions = reactive<VxeGridProps<RoleModel>>({
+  id: 'role-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'roleName', title: '角色名称', minWidth: 160 },
+    { field: 'updateTime', title: '最近一次修改时间', minWidth: 160 },
+    { field: 'updateBy', title: '操作者', minWidth: 160 },
+    { field: 'createTime', title: '创建时间', minWidth: 160 },
+    { field: 'createBy', title: '创建者', minWidth: 160 },
+    {
+      field: 'status', title: '启用状态', minWidth: 100, cellRender: {
+        name: 'VxeSwitch',
+        props: {
+          openLabel: '启用', openValue: '0',
+          closeLabel: '停用', closeValue: '1',
+        },
+        events: {
+          change({ row, rowIndex }, { value }) {
+            row.status = { '1': '0', '0': '1' }[ value as string ] as any;
+            updateRoleStatus(row, rowIndex, value);
+          },
+        },
+      },
+    },
+    {
+      title: '操作',
+      width: 200,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '修改', name: 'editRole' },
+          { content: '删除', status: 'error', name: 'deleteRole' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if ( name === 'editRole' ) { method = editRole; } else if ( name === 'deleteRole' ) { method = deleteRole; }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => rolesMethod(page, size, model.value), {
+    initialData: { data: [], total: 0 },
+    initialPage: 1,
+    initialPageSize: 100,
+    watchingStates: [ model ],
+    immediate: false,
+  },
+);
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+function updateRoleStatus(model: RoleModel, index: number, status: RoleModel['status']) {
+  const { roleName } = model;
+  const label = { '1': '停用', '0': '启用' }[ status ];
+  VxeUI.modal.confirm({
+    title: `启用状态`,
+    content: `确认要 ${ label } ${ roleName } 角色吗?`,
+    showClose: false,
+    onConfirm() {
+      updateRoleStatusMethod(model).then(() => {
+        notification.success({
+          message: `${ label }角色: ${ roleName }`,
+          description: '操作成功',
+        });
+        model.status = status;
+        replace(model, index);
+      });
+    },
+  });
+}
+
+function deleteRole(model: RoleModel, index: number) {
+  const { roleName } = model;
+  VxeUI.modal.confirm({
+    title: `删除角色`,
+    content: `确认要删除 ${ roleName }角色吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteRoleMethod(model).then(() => {
+        notification.success({
+          message: `删除角色: ${ roleName }`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editRole(model?: RoleModel, index?: number) {
+  console.log('editRole', model, index);
+  VxeUI.modal.open({
+    title: model?.roleId ? `修改角色` : `新增角色`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `role-edit-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(RoleEdit, <any> {
+          data: model, onSubmit(data: RoleModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`role-edit-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits"></vxe-form>
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #handle>
+          <vxe-button status="primary" @click="editRole()">新增</vxe-button>
+        </template>
+        <template #toolbar-extra>
+          <vxe-button style="margin-right: 12px;" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
+        </template>
+      </vxe-grid>
+    </main>
+    <footer class="flex-none">
+      <vxe-pager
+        v-model:current-page="page"
+        v-model:page-size="pageSize"
+        :total="total"
+        :layouts="['Home', 'PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'End', 'Sizes', 'FullJump', 'Total']"
+      />
+    </footer>
+  </div>
+</template>
+<style scoped lang="scss">
+.page-container {
+  padding: 0 24px;
+  max-height: var(--page-main-container);
+}
+</style>

+ 260 - 0
src/pages/index/system/user.vue

@@ -0,0 +1,260 @@
+<script setup lang="ts">
+import UserEdit                           from '@/components/UserEdit.vue';
+import UserPassword                       from '@/components/UserPassword.vue';
+import UserPreview                        from '@/components/UserPreview.vue';
+import { type UserModel, type UserQuery } from '@/model/system.model';
+
+import { branchMethod, deleteUserMethod, usersMethod } from '@/request/api/system.api';
+import { usePagination, useRequest }                   from 'alova/client';
+import { notification }                                from 'ant-design-vue';
+
+import {
+  VxeButton,
+  type VxeFormListeners,
+  type VxeFormProps,
+  type VxeGridInstance,
+  type VxeGridListeners,
+  type VxeGridProps,
+  VxeUI,
+} from 'vxe-pc-ui';
+
+
+const { data: branch, loading: branchLoading } = useRequest(branchMethod);
+
+const model = shallowRef<UserQuery>();
+const searchFormProps = reactive<VxeFormProps<UserQuery>>({
+  titleWidth: 100,
+  titleAlign: 'right',
+  titleColon: true,
+  data: {},
+  items: [
+    { field: 'userName', title: '系统账号', span: 6, itemRender: { name: 'VxeInput' } },
+    { field: 'nickName', title: '姓名', span: 6, itemRender: { name: 'VxeInput' } },
+    { field: '工号', title: '工号', span: 6, itemRender: { name: 'VxeInput' } },
+    { field: 'phonenumber', title: '手机号码', span: 6, itemRender: { name: 'VxeInput' } },
+    {
+      field: 'deptId', title: '医院 / 科室', span: 6, itemRender: {
+        name: 'VxeTreeSelect',
+        props: {
+          loading: computed(() => branchLoading.value),
+          options: computed(() => branch.value),
+          optionProps: { value: 'id' },
+        },
+      },
+    },
+    {
+      span: 6, itemRender: {
+        name: 'VxeButtonGroup',
+        options: [
+          { type: 'submit', content: '查询', status: 'primary' },
+          { type: 'reset', content: '清空' },
+        ],
+      },
+    },
+  ],
+});
+const searchFormEmits: VxeFormListeners<UserQuery> = {
+  submit({ data }) { model.value = { ...data }; },
+  reset({ data }) { model.value = { ...data }; },
+};
+
+const gridRef = ref<VxeGridInstance<UserModel>>();
+const gridOptions = reactive<VxeGridProps<UserModel>>({
+  id: 'user-list',
+  border: true,
+  showOverflow: true,
+  height: 'auto',
+  scrollY: { enabled: true, gt: 0 },
+  toolbarConfig: {
+    custom: true,
+    zoom: true,
+    slots: {
+      buttons: 'handle',
+      tools: 'toolbar-extra',
+    },
+  },
+  columnConfig: {
+    resizable: true,
+  },
+  customConfig: {
+    storage: true,
+  },
+  columns: [
+    { type: 'seq', width: 70, fixed: 'left' },
+    { field: 'userName', title: '系统账号', minWidth: 160 },
+    { field: 'nickName', title: '姓名', minWidth: 160 },
+    { field: 'dept.deptName', title: '医院 / 科室', minWidth: 160 },
+    { field: '工号', title: '工号', minWidth: 160 },
+    { field: 'phonenumber', title: '手机号码', minWidth: 160 },
+    {
+      field: 'roles', title: '角色', minWidth: 160, formatter: ({ cellValue, row }) => {
+        return row.roles?.join('/') ?? '';
+      },
+    },
+    {
+      title: '操作',
+      width: 200,
+      cellRender: {
+        name: 'VxeButtonGroup',
+        props: {
+          mode: 'text',
+        },
+        options: [
+          { content: '修改', name: 'editUser' },
+          { content: '删除', status: 'error', name: 'deleteUser' },
+        ],
+        events: {
+          click({ row, rowIndex }, { name }) {
+            let method;
+            if ( name === 'editUser' ) { method = editUser; } else if ( name === 'deleteUser' ) { method = deleteUser; }
+            method?.(row, rowIndex);
+          },
+        },
+      },
+      params: {
+        cellClick: false,
+      },
+    },
+  ],
+  data: [],
+});
+const gridEvents: VxeGridListeners = {
+  cellClick: ({ row, rowIndex, column }) => {
+    if ( column.params?.cellClick === false ) return;
+    previewUser(row, rowIndex);
+  },
+};
+
+const { loading, page, pageSize, total, onSuccess, replace, refresh, remove } = usePagination(
+  (page, size) => usersMethod(page, size, model.value), {
+    initialData: { data: [], total: 0 },
+    initialPage: 1,
+    initialPageSize: 100,
+    watchingStates: [ model ],
+    immediate: false,
+  },
+);
+onSuccess(({ data: { data } }) => {
+  gridRef.value?.loadData(data);
+});
+
+onMounted(() => {
+  model.value = toRaw(searchFormProps.data);
+});
+
+function deleteUser(model: UserModel, index: number) {
+  const { userName } = model;
+  VxeUI.modal.confirm({
+    title: `删除用户`,
+    content: `确认要删除 ${ userName } 用户吗?`,
+    showClose: false,
+    onConfirm() {
+      deleteUserMethod(model).then(() => {
+        notification.success({
+          message: `删除用户: ${ userName }`,
+          description: '操作成功',
+        });
+        refresh(page.value);
+      });
+    },
+  });
+}
+
+function editUser(model?: UserModel, index?: number) {
+  VxeUI.modal.open({
+    title: model?.userId ? `修改用户` : `新增用户`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `user-edit-modal`,
+    remember: true,
+    storage: true,
+    slots: {
+      default() {
+        return h(UserEdit, <any> {
+          data: model, onSubmit(data?: UserModel) {
+            refresh(page.value);
+            VxeUI.modal.close(`user-edit-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+
+function previewUser(model: UserModel, index?: number) {
+  VxeUI.drawer.open({
+    title: `用户信息`,
+    maskClosable: true,
+    escClosable: true,
+    slots: {
+      default() {
+        return h(UserPreview, <any> { data: model });
+      },
+      corner() {
+        return h(VxeButton, {
+          content: '修改登录密码', size: 'mini', onClick() {
+            updateUserPassword(model, index);
+          },
+        });
+      },
+    },
+  });
+}
+
+function updateUserPassword(model: UserModel, index?: number) {
+  const { userName } = model;
+  VxeUI.modal.open({
+    title: `重置 ${ userName } 登录密码`,
+    escClosable: true,
+    destroyOnClose: true,
+    id: `user-edit-password-modal`,
+    remember: true,
+    storage: true,
+    mask: false,
+    slots: {
+      default() {
+        return h(UserPassword, <any> {
+          data: model, onSubmit(data?: UserModel) {
+            notification.success({
+              message: `重置用户: ${ userName } 的登录密码`,
+              description: '操作成功',
+            });
+            VxeUI.modal.close(`user-edit-password-modal`);
+          },
+        });
+      },
+    },
+  });
+}
+</script>
+<template>
+  <div class="page-container flex flex-col">
+    <header class="flex-none">
+      <vxe-form v-bind="searchFormProps" v-on="searchFormEmits" />
+    </header>
+    <main class="flex-auto overflow-hidden">
+      <vxe-grid ref="gridRef" v-bind="gridOptions" v-on="gridEvents" :loading="loading">
+        <template #handle>
+          <vxe-button status="primary" @click="editUser()">新增</vxe-button>
+        </template>
+        <template #toolbar-extra>
+          <vxe-button style="margin-right: 12px;" icon="vxe-icon-repeat" circle @click="refresh(page)"></vxe-button>
+        </template>
+      </vxe-grid>
+    </main>
+    <footer class="flex-none">
+      <vxe-pager
+        v-model:current-page="page"
+        v-model:page-size="pageSize"
+        :total="total"
+        :layouts="['Home', 'PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'End', 'Sizes', 'FullJump', 'Total']"
+      />
+    </footer>
+  </div>
+</template>
+<style scoped lang="scss">
+.page-container {
+  padding: 0 24px;
+  max-height: var(--page-main-container);
+}
+</style>

+ 4 - 1
src/request/alova.ts

@@ -3,13 +3,16 @@ import fetchAdapter     from 'alova/fetch';
 import VueHook          from 'alova/vue';
 import { notification } from 'ant-design-vue';
 import mockAdapter      from './mock';
+import { getToken }     from './tools';
 
 
 const request = createAlova({
   baseURL: import.meta.env.BASE_URL,
   statesHook: VueHook,
   requestAdapter: import.meta.env.DEV ? mockAdapter() : fetchAdapter(),
-
+  async beforeRequest(method) {
+    if ( !method.config.meta?.ignoreToken ) method.config.headers.Authorization ??= getToken();
+  },
   responded: {
     async onSuccess(response, method) {
       let ignoreException = false;

+ 59 - 0
src/request/api/system.api.ts

@@ -0,0 +1,59 @@
+import type { List, Tree }                                 from '@/model';
+import type { RoleModel, RoleQuery, UserModel, UserQuery } from '@/model/system.model';
+
+import request from '@/request/alova';
+
+
+export function branchMethod() {
+  return request.Get<Tree<{ id: string, label: string }>>(`/prod-api/system/user/deptTree`);
+}
+
+export function usersMethod(page: number, size: number, query?: UserQuery) {
+  return request.Get<List<RoleModel>>(`/prod-api/system/user/list`, {
+    params: { pageNum: page, pageSize: size, ...query },
+  });
+}
+
+export function userMethod(data: Partial<UserModel>) {
+  return request.Get<UserModel>(`/prod-api/system/user/${ data.userId }`);
+}
+
+export function editUserMethod(data: Partial<UserModel>) {
+  return data.userId ? request.Put(`/prod-api/system/user`, data) : request.Post(`/prod-api/system/user`, data);
+}
+
+export function deleteUserMethod(data: Partial<UserModel>) {
+  return request.Delete(`/prod-api/system/user/${ data.userId }`);
+}
+
+export function updateUserPasswordMethod(data: Partial<UserModel>) {
+  return request.Put(`/prod-api/system/user/resetPwd`, data);
+}
+
+
+export function rolesAllMethod() {
+  return request.Get<List<RoleModel>>(`/prod-api/system/role/optionselect`);
+}
+
+
+export function rolesMethod(page: number, size: number, query?: RoleQuery) {
+  return request.Get<List<RoleModel>>(`/prod-api/system/role/list`, {
+    params: { pageNum: page, pageSize: size, ...query },
+  });
+}
+
+export function roleMethod(data: Partial<RoleModel>) {
+  return request.Get<RoleModel>(`/prod-api/system/role/${ data.roleId }`);
+}
+
+export function editRoleMethod(data: Partial<RoleModel>) {
+  return data.roleId ? request.Put(`/prod-api/system/role`, data, {}) : request.Post(`/prod-api/system/role`, data);
+}
+
+export function deleteRoleMethod(data: Partial<RoleModel>) {
+  return request.Delete(`/prod-api/system/role/${ data.roleId }`);
+}
+
+export function updateRoleStatusMethod(data: Partial<RoleModel>) {
+  return request.Put(`/prod-api/system/role/changeStatus`, { roleId: data.roleId, status: data.status });
+}

+ 6 - 0
src/request/tools.ts

@@ -0,0 +1,6 @@
+import pinia, { useAccountStore } from '@/stores';
+
+
+export function getToken() {
+  return useAccountStore(pinia).token;
+}

+ 11 - 0
src/themes/index.scss

@@ -1,3 +1,14 @@
 @import url("@unocss/reset/normalize.css");
 @import url("ant-design-vue/dist/reset.css");
 @import url("vue-virtual-scroller/dist/vue-virtual-scroller.css");
+
+@import url("vxe-table/styles/cssvar.scss");
+@import url("vxe-pc-ui/styles/cssvar.scss");
+
+.vxe-drawer {
+  &--header-title,
+  &--close-btn {
+    display: flex;
+    align-items: center;
+  }
+}

+ 17 - 4
vite.config.ts

@@ -12,8 +12,9 @@ import Components               from 'unplugin-vue-components/vite';
 import { VueRouterAutoImports } from 'unplugin-vue-router';
 import VueRouter                from 'unplugin-vue-router/vite';
 
-import { defineConfig, loadEnv } from 'vite';
-import vueDevTools      from 'vite-plugin-vue-devtools';
+import { defineConfig, loadEnv }   from 'vite';
+import { lazyImport, VxeResolver } from 'vite-plugin-lazy-import';
+import vueDevTools                 from 'vite-plugin-vue-devtools';
 
 
 const dts = `./@types/` as const;
@@ -52,13 +53,25 @@ export default defineConfig((configEnv) => {
         ],
         dts: `${ dts }components.d.ts`,
       }),
+      lazyImport({
+        resolvers: [
+          VxeResolver({
+            libraryName: 'vxe-table',
+            importStyle: 'scss' as any,
+          }),
+          VxeResolver({
+            libraryName: 'vxe-pc-ui',
+            importStyle: 'scss' as any,
+          }),
+        ],
+      }),
     ],
     resolve: {
       alias: {
         '@': fileURLToPath(new URL('./src', import.meta.url)),
       },
     },
-    server:{
+    server: {
       host: true,
       open: true,
       proxy: {
@@ -69,6 +82,6 @@ export default defineConfig((configEnv) => {
           logLevel: 'debug',
         },
       },
-    }
+    },
   };
 });