Parcourir la source

feat: add system user view

xingyu4j il y a 3 semaines
Parent
commit
aac626da32

+ 85 - 0
apps/backend-mock/api/system/user/list.ts

@@ -0,0 +1,85 @@
+import { faker } from '@faker-js/faker';
+import { eventHandler, getQuery } from 'h3';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
+
+const formatterCN = new Intl.DateTimeFormat('zh-CN', {
+  timeZone: 'Asia/Shanghai',
+  year: 'numeric',
+  month: '2-digit',
+  day: '2-digit',
+  hour: '2-digit',
+  minute: '2-digit',
+  second: '2-digit',
+});
+
+function generateMockDataList(count: number) {
+  const dataList = [];
+
+  for (let i = 0; i < count; i++) {
+    const dataItem: Record<string, any> = {
+      id: faker.string.uuid(),
+      name: faker.commerce.product(),
+      status: faker.helpers.arrayElement([0, 1]),
+      createTime: formatterCN.format(
+        faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
+      ),
+      deptId: faker.string.uuid(),
+      remark: faker.lorem.sentence(),
+    };
+
+    dataList.push(dataItem);
+  }
+
+  return dataList;
+}
+
+const mockData = generateMockDataList(100);
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+
+  const {
+    page = 1,
+    pageSize = 20,
+    name,
+    id,
+    remark,
+    startTime,
+    endTime,
+    deptId,
+    status,
+  } = getQuery(event);
+  let listData = structuredClone(mockData);
+  if (name) {
+    listData = listData.filter((item) =>
+      item.name.toLowerCase().includes(String(name).toLowerCase()),
+    );
+  }
+  if (id) {
+    listData = listData.filter((item) =>
+      item.id.toLowerCase().includes(String(id).toLowerCase()),
+    );
+  }
+  if (remark) {
+    listData = listData.filter((item) =>
+      item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
+    );
+  }
+  if (startTime) {
+    listData = listData.filter((item) => item.createTime >= startTime);
+  }
+  if (endTime) {
+    listData = listData.filter((item) => item.createTime <= endTime);
+  }
+  if (['0', '1'].includes(status as string)) {
+    listData = listData.filter((item) => item.status === Number(status));
+  }
+  if (deptId) {
+    listData = listData.filter((item) => item.deptId === deptId);
+  }
+  return usePageResponseSuccess(page as string, pageSize as string, listData);
+});

+ 1 - 0
playground/src/api/system/index.ts

@@ -1,3 +1,4 @@
 export * from './dept';
 export * from './menu';
 export * from './role';
+export * from './user';

+ 55 - 0
playground/src/api/system/user.ts

@@ -0,0 +1,55 @@
+import type { Recordable } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+export namespace SystemUserApi {
+  export interface SystemUser {
+    [key: string]: any;
+    id: string;
+    name: string;
+    permissions: string[];
+    remark?: string;
+    status: 0 | 1;
+  }
+}
+
+/**
+ * 获取用户列表数据
+ */
+async function getUserList(params: Recordable<any>) {
+  return requestClient.get<Array<SystemUserApi.SystemUser>>(
+    '/system/user/list',
+    { params },
+  );
+}
+
+/**
+ * 创建用户
+ * @param data 用户数据
+ */
+async function createUser(data: Omit<SystemUserApi.SystemUser, 'id'>) {
+  return requestClient.post('/system/user', data);
+}
+
+/**
+ * 更新用户
+ *
+ * @param id 用户 ID
+ * @param data 用户数据
+ */
+async function updateUser(
+  id: string,
+  data: Omit<SystemUserApi.SystemUser, 'id'>,
+) {
+  return requestClient.put(`/system/user/${id}`, data);
+}
+
+/**
+ * 删除用户
+ * @param id 用户 ID
+ */
+async function deleteUser(id: string) {
+  return requestClient.delete(`/system/user/${id}`);
+}
+
+export { createUser, deleteUser, getUserList, updateUser };

+ 13 - 0
playground/src/locales/langs/en-US/system.json

@@ -1,5 +1,18 @@
 {
   "title": "System Management",
+  "user": {
+    "title": "User Management",
+    "list": "User List",
+    "name": "User",
+    "userName": "User Name",
+    "id": "User ID",
+    "dept": "Department",
+    "status": "Status",
+    "remark": "Remark",
+    "createTime": "Creation Time",
+    "operation": "Operation",
+    "placeholder": "Search Department..."
+  },
   "dept": {
     "name": "Department",
     "title": "Department Management",

+ 13 - 0
playground/src/locales/langs/zh-CN/system.json

@@ -1,5 +1,18 @@
 {
   "title": "系统管理",
+  "user": {
+    "title": "用户管理",
+    "list": "用户列表",
+    "name": "用户名",
+    "userName": "用户名称",
+    "id": "用户ID",
+    "dept": "部门",
+    "status": "状态",
+    "remark": "备注",
+    "createTime": "创建时间",
+    "operation": "操作",
+    "placeholder": "搜索部门..."
+  },
   "dept": {
     "list": "部门列表",
     "createTime": "创建时间",

+ 9 - 0
playground/src/router/routes/modules/system.ts

@@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
     name: 'System',
     path: '/system',
     children: [
+      {
+        path: '/system/user',
+        name: 'SystemUser',
+        meta: {
+          icon: 'mdi:user',
+          title: $t('system.user.title'),
+        },
+        component: () => import('#/views/system/user/list.vue'),
+      },
       {
         path: '/system/role',
         name: 'SystemRole',

+ 135 - 0
playground/src/views/system/user/data.ts

@@ -0,0 +1,135 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn, VxeTableGridColumns } from '#/adapter/vxe-table';
+import type { SystemUserApi } from '#/api';
+
+import { getDeptList } from '#/api';
+import { $t } from '#/locales';
+
+export function useFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.user.name'),
+      rules: 'required',
+    },
+    {
+      component: 'ApiTreeSelect',
+      componentProps: {
+        allowClear: true,
+        api: getDeptList,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+      },
+      fieldName: 'deptId',
+      label: $t('system.user.dept'),
+      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.user.status'),
+    },
+    {
+      component: 'Textarea',
+      fieldName: 'remark',
+      label: $t('system.user.remark'),
+    },
+  ];
+}
+
+export function useGridFormSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.user.name'),
+    },
+    { component: 'Input', fieldName: 'id', label: $t('system.user.id') },
+    {
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        options: [
+          { label: $t('common.enabled'), value: 1 },
+          { label: $t('common.disabled'), value: 0 },
+        ],
+      },
+      fieldName: 'status',
+      label: $t('system.user.status'),
+    },
+    {
+      component: 'Input',
+      fieldName: 'remark',
+      label: $t('system.user.remark'),
+    },
+    {
+      component: 'RangePicker',
+      fieldName: 'createTime',
+      label: $t('system.user.createTime'),
+    },
+  ];
+}
+
+export function useColumns<T = SystemUserApi.SystemUser>(
+  onActionClick: OnActionClickFn<T>,
+  onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
+): VxeTableGridColumns {
+  return [
+    {
+      field: 'name',
+      title: $t('system.user.name'),
+      width: 200,
+    },
+    {
+      field: 'id',
+      title: $t('system.user.id'),
+      width: 200,
+    },
+    {
+      cellRender: {
+        attrs: { beforeChange: onStatusChange },
+        name: onStatusChange ? 'CellSwitch' : 'CellTag',
+      },
+      field: 'status',
+      title: $t('system.user.status'),
+      width: 100,
+    },
+    {
+      field: 'remark',
+      minWidth: 100,
+      title: $t('system.user.remark'),
+    },
+    {
+      field: 'createTime',
+      title: $t('system.user.createTime'),
+      width: 200,
+    },
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.user.name'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+      },
+      field: 'operation',
+      fixed: 'right',
+      title: $t('system.user.operation'),
+      width: 130,
+    },
+  ];
+}

+ 222 - 0
playground/src/views/system/user/list.vue

@@ -0,0 +1,222 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemDeptApi, SystemUserApi } from '#/api';
+
+import { onMounted, ref, watch } from 'vue';
+
+import { Page, Tree, useVbenDrawer } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, Card, InputSearch, message, Modal } from 'antdv-next';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteUser, getDeptList, getUserList, updateUser } from '#/api';
+import { $t } from '#/locales';
+
+import { useColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const deptList = ref<SystemDeptApi.SystemDept[]>([]);
+const inputSearchValue = ref('');
+const selectedDeptId = ref<string>('');
+
+const [FormDrawer, formDrawerApi] = useVbenDrawer({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
+    schema: useGridFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useColumns(onActionClick, onStatusChange),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getUserList({
+            page: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+            deptId: selectedDeptId.value,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    toolbarConfig: {
+      custom: true,
+      export: false,
+      refresh: true,
+      search: true,
+      zoom: true,
+    },
+  } as VxeTableGridOptions<SystemUserApi.SystemUser>,
+});
+
+function onActionClick(e: OnActionClickParams<SystemUserApi.SystemUser>) {
+  switch (e.code) {
+    case 'delete': {
+      onDelete(e.row);
+      break;
+    }
+    case 'edit': {
+      onEdit(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: number,
+  row: SystemUserApi.SystemUser,
+) {
+  const status: Recordable<string> = {
+    0: '禁用',
+    1: '启用',
+  };
+  try {
+    await confirm(
+      `你要将${row.name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
+      `切换状态`,
+    );
+    await updateUser(row.id, { status: newStatus });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function onEdit(row: SystemUserApi.SystemUser) {
+  formDrawerApi.setData(row).open();
+}
+
+function onDelete(row: SystemUserApi.SystemUser) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  deleteUser(row.id)
+    .then(() => {
+      message.success({
+        content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+        key: 'action_process_msg',
+      });
+      onRefresh();
+    })
+    .catch(() => {
+      hideLoading();
+    });
+}
+
+function onRefresh() {
+  gridApi.query();
+}
+
+function onCreate() {
+  formDrawerApi.setData({}).open();
+}
+
+async function loadDeptList() {
+  try {
+    const res = await getDeptList();
+    deptList.value = res;
+  } catch (error) {
+    console.error('Failed to load department list:', error);
+  }
+}
+
+function selectDept(v: string) {
+  selectedDeptId.value = v;
+  gridApi.query();
+}
+
+function searchDept(value: string) {
+  if (!value) {
+    loadDeptList();
+    return;
+  }
+  const filtered = deptList.value.filter((dept) =>
+    dept.name.toLowerCase().includes(value.toLowerCase()),
+  );
+  deptList.value = filtered;
+}
+
+onMounted(() => {
+  loadDeptList();
+});
+
+watch(inputSearchValue, (value) => {
+  searchDept(value);
+});
+</script>
+<template>
+  <Page auto-content-height>
+    <FormDrawer @success="onRefresh" />
+    <div class="flex size-full">
+      <Card class="w-1/6">
+        <InputSearch
+          v-model:value="inputSearchValue"
+          :placeholder="$t('system.user.placeholder')"
+        />
+        <Tree
+          label-field="name"
+          value-field="id"
+          :tree-data="deptList"
+          :default-expanded-level="2"
+          @select="selectDept"
+        />
+      </Card>
+
+      <div class="w-5/6 ml-4">
+        <Grid :table-title="$t('system.user.list')">
+          <template #toolbar-tools>
+            <Button type="primary" @click="onCreate">
+              <Plus class="size-5" />
+              {{ $t('ui.actionTitle.create', [$t('system.user.name')]) }}
+            </Button>
+          </template>
+        </Grid>
+      </div>
+    </div>
+  </Page>
+</template>

+ 138 - 0
playground/src/views/system/user/modules/form.vue

@@ -0,0 +1,138 @@
+<script lang="ts" setup>
+import type { DataNode } from 'antdv-next/dist/tree';
+
+import type { Recordable } from '@vben/types';
+
+import type { SystemUserApi } from '#/api/system/user';
+
+import { computed, nextTick, ref } from 'vue';
+
+import { Tree, useVbenDrawer } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { Spin } from 'antdv-next';
+
+import { useVbenForm } from '#/adapter/form';
+import { getMenuList } from '#/api/system/menu';
+import { createUser, updateUser } from '#/api/system/user';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emits = defineEmits(['success']);
+
+const formData = ref<SystemUserApi.SystemUser>();
+
+const [Form, formApi] = useVbenForm({
+  schema: useFormSchema(),
+  showDefaultActions: false,
+});
+
+const permissions = ref<DataNode[]>([]);
+const loadingPermissions = ref(false);
+
+const id = ref();
+const [Drawer, drawerApi] = useVbenDrawer({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+    const values = await formApi.getValues();
+    drawerApi.lock();
+    (id.value ? updateUser(id.value, values) : createUser(values))
+      .then(() => {
+        emits('success');
+        drawerApi.close();
+      })
+      .catch(() => {
+        drawerApi.unlock();
+      });
+  },
+
+  async onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = drawerApi.getData<SystemUserApi.SystemUser>();
+      formApi.resetForm();
+
+      if (data) {
+        formData.value = data;
+        id.value = data.id;
+      } else {
+        id.value = undefined;
+      }
+
+      if (permissions.value.length === 0) {
+        await loadPermissions();
+      }
+      // Wait for Vue to flush DOM updates (form fields mounted)
+      await nextTick();
+      if (data) {
+        formApi.setValues(data);
+      }
+    }
+  },
+});
+
+async function loadPermissions() {
+  loadingPermissions.value = true;
+  try {
+    const res = await getMenuList();
+    permissions.value = res as unknown as DataNode[];
+  } finally {
+    loadingPermissions.value = false;
+  }
+}
+
+const getDrawerTitle = computed(() => {
+  return formData.value?.id
+    ? $t('common.edit', $t('system.user.name'))
+    : $t('common.create', $t('system.user.name'));
+});
+
+function getNodeClass(node: Recordable<any>) {
+  const classes: string[] = [];
+  if (node.value?.type === 'button') {
+    classes.push('inline-flex');
+  }
+
+  return classes.join(' ');
+}
+</script>
+<template>
+  <Drawer :title="getDrawerTitle">
+    <Form>
+      <template #permissions="slotProps">
+        <Spin :spinning="loadingPermissions" :classes="{ root: 'w-full' }">
+          <Tree
+            :tree-data="permissions"
+            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>
+          </Tree>
+        </Spin>
+      </template>
+    </Form>
+  </Drawer>
+</template>
+<style lang="css" scoped>
+:deep(.ant-tree-title) {
+  .tree-actions {
+    @apply ml-5 hidden;
+  }
+}
+
+:deep(.ant-tree-title:hover) {
+  .tree-actions {
+    @apply ml-5 flex flex-auto justify-end;
+  }
+}
+</style>