后台管理系统开发(用户表格 前端)

97 阅读7分钟

实现效果

PC端 表格显示效果

image.png

image.png

PE端 表格显示效果

image.png

image.png

展开信息

image.png

image.png

编辑和更新对话框

image.png

image.png

重置密码对话框

image.png

image.png

删除用户

image.png

image.png

查询

image.png

image.png

代码实现

用户视图组件

<!--
 * 用户管理 视图组件
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/2 3:43
-->
<script lang="ts" setup>
  import { useTableSharedMeta } from './useTableMeta';
  import UserModal from './components/UserModal.vue';
  import DelModal from './components/DelUserModal.vue';
  import UserTable from './components/UserTable.vue';
  import { useRequest } from '#imports';
  import { getUserInfoApi } from '~/api/user';

  defineOptions({
    name: 'sys-user',
  });

  // 布局响应
  const { isTablet, isDesktop } = useLayout();

  // 用户更新,创建对话框是否显示
  const userModalVisible = ref(false);

  // 用户删除对话框是否显示
  const delUserModalVisible = ref(false);

  // 用户表格
  const userTableRef = ref<InstanceType<typeof UserTable>>();

  // 关系 store , 随着组件销毁而销毁
  const { tableLoading, tableRefresh, tableSize, tableMap, selectedKeys } =
    useTableSharedMeta();

  // 用户更新,创建对话框
  const userModalRef = ref<InstanceType<typeof UserModal>>();

  const currentElement = useCurrentElement<HTMLElement>();
  const { isFullscreen, toggle: toggleFullscreen } =
    useFullscreen(currentElement);

  /**
   * [button] 创建按钮创建用户
   */
  const onCreateUser = () => {
    userModalVisible.value = true;
  };

  /**
   * 更新用户信息
   */
  // 获取 用户信息请求
  const { runAsync: userInfoRunAsync, loading: userInfoLoading } =
    useRequest(getUserInfoApi);

  const onEditUser = async (uid: string) => {
    const userEntity = await userInfoRunAsync(uid);

    // 加载用户信息
    userModalRef.value!.loadUserInfo(userEntity);
  };
</script>

<template>
  <a-card
    :body-style="{
      padding: isTablet ? '0.75rem 0.7rem' : '1rem',
    }"
    :bordered="false"
    class="drop-shadow-sm! rounded-lg!"
  >
    <UserModal
      ref="userModalRef"
      v-model:visible="userModalVisible"
      class="z-100"
    />

    <DelModal v-model="delUserModalVisible" />

    <a-space class="pb-2.5 justify-between" fill>
      <a-space>
        <a-button
          :class="{ 'arco-btn-only-icon': isTablet }"
          :size="'small'"
          type="primary"
          @click="onCreateUser"
        >
          <span v-if="!isTablet">创建用户</span>
          <template #icon>
            <i class="i-gg:user-add" />
          </template>
        </a-button>

        <a-button
          v-if="selectedKeys.length > 0"
          :class="{ 'arco-btn-only-icon': isTablet }"
          :size="'small'"
          :status="'danger'"
          @click="delUserModalVisible = true"
        >
          <span v-if="!isTablet">删除用户({{ selectedKeys.length }})</span>
          <template #icon>
            <i class="i-tabler:trash" />
          </template>
        </a-button>
      </a-space>

      <a-space>
        <a-button
          :class="{ 'arco-btn-only-icon': isTablet }"
          :loading="tableLoading"
          :size="'small'"
          @click="tableRefresh"
        >
          <span v-if="!isTablet">刷新</span>
          <template #icon>
            <i class="i-tabler:refresh" />
          </template>
        </a-button>

        <a-select v-model="tableSize" :size="'small'">
          <template #label="{ data }">
            <a-space>
              <i class="i-tabler:arrow-autofit-height" />
              <span>{{ data.label }}</span>
            </a-space>
          </template>

          <a-option v-for="(value, key) in tableMap" :key="key" :value="key">
            {{ value }}
          </a-option>
        </a-select>

        <a-button
          :class="{ 'arco-btn-only-icon': isTablet }"
          :size="'small'"
          :type="isFullscreen ? 'primary' : 'secondary'"
          @click="toggleFullscreen"
        >
          <span v-if="!isTablet">全屏</span>
          <template #icon>
            <i class="i-tabler-arrows-maximize" />
          </template>
        </a-button>
      </a-space>
    </a-space>

    <UserTable
      ref="userTableRef"
      :edit-loading="userInfoLoading"
      @on-edit-user="onEditUser"
    />
  </a-card>
</template>

<style lang="scss" scoped>
  :deep(.arco-tag-size-medium) {
    font-size: 0.8rem;
  }

  :deep(.arco-table-cell-fixed-expand) {
    padding: 0 !important;
  }

  :deep(.arco-select-view-single) {
    width: 7rem;
  }

  :deep(.arco-table-cell) {
    @apply px-2 lg:px-3;
  }
</style>

表格渲染

定义 useTableMeta [SharedComposable]

  • pages/sys/user/useTableMeta.ts

使用 vueuse createSharedComposable 持久在视图组件生命周期中,随着组件销毁而销毁。 便于组件向下传递使用。

import type { PaginationProps, TableColumnData } from '@arco-design/web-vue';
import { toNumber } from 'lodash-es';
import type { TableRowSelection } from '@arco-design/web-vue/es/table/interface';
import { usePagination } from '#imports';
import { getUserPageApi } from '~/api/user';

export const useTableMeta = () => {
  const tableColumns = ref<TableColumnData[]>([
    {
      title: '头像',
      dataIndex: 'avatarUrl',
      slotName: 'avatarUrl',
      width: 40,
      align: 'center',
    },
    {
      title: '用户账号',
      dataIndex: 'username',
      slotName: 'username',
      width: 120,
      align: 'left',
    },
    {
      title: '用户名称',
      dataIndex: 'nickName',
      slotName: 'nickName',
      width: 120,
      align: 'center',
    },
    {
      title: '邮件地址',
      dataIndex: 'email',
      slotName: 'email',
      align: 'center',
      width: 180,
    },
    {
      title: '性别',
      dataIndex: 'gender.label',
      slotName: 'gender',
      width: 80,
      align: 'center',
    },
    {
      title: '地址名称',
      dataIndex: 'address',
      slotName: 'address',
      width: 160,
      align: 'center',
    },

    {
      title: '状态',
      dataIndex: 'status.label',
      slotName: 'status',
      align: 'center',
      width: 50,
    },
    {
      title: '创建于',
      dataIndex: 'createAt',
      slotName: 'createAt',
      width: 100,
      align: 'center',
    },
    {
      title: '操作',
      dataIndex: 'operation',
      slotName: 'operation',
      width: 50,
      align: 'center',
      fixed: 'right',
    },
  ]);

  const { isTablet } = useLayout();

  const {
    data,
    loading,
    refresh,
    pageSize,
    total,
    current,
    changePageSize,
    changeCurrent,
  } = usePagination(getUserPageApi, {
    manual: false,
    defaultParams: [
      {
        current: 1,
        size: 8,
      },
    ],
    pagination: {
      currentKey: 'current',
      pageSizeKey: 'size',
      totalKey: 'total',
    },
  });

  const rowSelection: TableRowSelection = {
    type: 'checkbox',
    showCheckedAll: true,
    width: 36,
  };

 // 选择用户 user id
  const selectedKeys = ref<string[]>([]);

  // 选择更新用户状态 [那个 button 需要显示加载状态]
  const selectLoadingKey = ref('');

  const tableData = computed(() => {
    return data.value?.records || [];
  });

  const tablePagination = computed(() => {
    return <PaginationProps>{
      current: current.value,
      pageSize: pageSize.value,
      total: toNumber(total.value),
      showTotal: true,
      showJumper: true,
      showPageSize: true,
      pageSizeOptions: [5, 8, 10, 15, 20],
      simple: isTablet.value,
    };
  });

  const tableSize = ref<'mini' | 'medium' | 'large' | 'small'>('small');
  const tableMap = {
    large: '宽松',
    medium: '中等',
    small: '紧凑',
    mini: '迷你',
  };

  return {
    tableColumns,
    tableData,
    tableLoading: loading,
    tableRefresh: refresh,
    tablePagination,
    changePageSize,
    changeCurrent,
    tableSize,
    tableMap,
    rowSelection,
    selectedKeys,
    selectLoadingKey,
  };
};

export const useTableSharedMeta = createSharedComposable(useTableMeta);

表格组件

<!--
 * 用户表格
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/2 13:43
-->
<script lang="ts" setup>
  import type { DescData } from '@arco-design/web-vue/es/descriptions/interface';
  import { useTableSharedMeta } from '../useTableMeta';
  import PasswordModal from './RestUserPasswordModal.vue';
  import { SYS_STATUS } from '~/key/dict';
  import { patchUserStatusApi } from '~/api/user';
  import { useRequest } from '#imports';

  defineProps<{
    editLoading: boolean;
  }>();

  // 响应布局
  const { isTablet } = useLayout();

  const emits = defineEmits<{
    /**
     * 点击编辑用户
     *
     * @param e 事件名称
     * @param userId 用户 id
     */
    (e: 'onEditUser', userId: string): void;
  }>();

  const {
    tableColumns,
    tableData,
    tableLoading,
    tableRefresh,
    tablePagination,
    changePageSize,
    changeCurrent,
    tableSize,
    rowSelection,
    selectedKeys,
    selectLoadingKey,
  } = useTableSharedMeta();

  const expandedKeys = ref<string[]>([]);

  /**
   * 更新用户信息
   */
  const { run: patchUserStatus, loading: patchUserStatusLoading } = useRequest(
    patchUserStatusApi,
    {
      onSuccess: () => {
        // 刷新表格
        tableRefresh();
        Message.success('修改用户状态成功');
      },
      onError: (error) => {
        Message.error(`修改用户状态失败: ${error.message}`);
      },
      onAfter() {
        // 重置选择更新用户状态的key
        selectLoadingKey.value = '';
      },
    }
  );

  /**
   * 用户状态修改
   *
   * @param userId 用户 id
   * @param status 用户状态
   */
  const onChangeUserStatus = (userId: string, status: string) => {
    selectLoadingKey.value = userId;
    patchUserStatus(userId, status);
  };

  /**
   * 展开用户信息
   *
   * @param userId 用户 id
   */
  const onExpand = (userId: string) => {
    const findIndex = expandedKeys.value.findIndex((item) => item === userId);
    if (findIndex !== -1) {
      expandedKeys.value = [];
      return;
    }
    expandedKeys.value = [userId];
  };

  /**
   * 用户描述
   *
   * @param user
   */
  const toDescriptions = (user: Record<string, any>): DescData[] => {
    return Object.keys(user).map((key) => {
      const element = user[key];
      return {
        value: (element.label ? element.label : element) as string,
        label: key + '',
      };
    });
  };

  /**
   * 编辑用户
   *
   * @param uid 用户 id
   */
  const onEditUser = (uid: string) => {
    selectLoadingKey.value = uid;
    emits('onEditUser', uid);
  };
</script>

<template>
  <a-table
    v-model:selected-keys="selectedKeys"
    :bordered="false"
    :columns="tableColumns"
    :data="tableData"
    :expanded-keys="expandedKeys"
    :loading="tableLoading"
    :page-position="'bottom'"
    :pagination="tablePagination"
    :row-selection="rowSelection"
    :size="tableSize"
    default-expand-all-rows
    row-key="userId"
    scrollbar
    @page-change="changeCurrent"
    @page-size-change="changePageSize"
  >
    <template #expand-row="{ record }">
      <a-descriptions
        :bordered="true"
        :column="isTablet ? 1 : 4"
        :data="toDescriptions(record)"
        :layout="isTablet ? 'horizontal' : 'inline-vertical'"
        :size="'mini'"
        :table-layout="'fixed'"
        class="py-2.5 px-0"
        style="background-color: var(--color-bg-2)"
      />
    </template>

    <template #avatarUrl="{ record }">
      <a-avatar
        :image-url="record.avatarUrl"
        :shape="'square'"
        :size="isTablet ? 34 : 36"
        @click="onExpand(record.userId)"
      />
    </template>

    <template #status="{ record }">
      <a-spin
        :loading="patchUserStatusLoading && selectLoadingKey === record.userId"
      >
        <SysDictSelect
          :dict-type="SYS_STATUS"
          :disabled="record.userId <= 1"
          :model-value="record.status?.code"
          :size="tableSize"
          @change="onChangeUserStatus(record.userId, $event as string)"
        />
      </a-spin>
    </template>

    <template #username="{ record }">
      <NuxtLink v-if="record.userId" :to="`/sys/user/${record.userId}`">
        <a-tag color="arcoblue">
          {{ record.username }}
        </a-tag>
      </NuxtLink>
    </template>

    <template #createAt="{ record }">
      <span>
        {{ $dayjs(record.createAt).fromNow() }}
      </span>
    </template>

    <template #gender="{ record }">
      <a-tag v-if="record.gender?.label">
        {{ record.gender.label }}
      </a-tag>
    </template>

    <template #address="{ record }">
      <a-tooltip v-if="record.address" :content="record.address">
        <span>
          {{ record.address.split(' ')[0] }}
        </span>
      </a-tooltip>
    </template>

    <template #operation="{ record }">
      <a-button-group :size="tableSize">
        <a-space :size="'mini'">
          <a-button
            :loading="selectLoadingKey === record.userId && editLoading"
            @click="onEditUser(record.userId)"
          >
            <template #icon>
              <i class="i-tabler:pencil-minus" />
            </template>
          </a-button>
          <a-tooltip content="重置密码">
            <PasswordModal
              :user-id="record.userId"
              :user-name="record.username"
            />
          </a-tooltip>
        </a-space>
      </a-button-group>
    </template>
  </a-table>
</template>

<style scoped></style>

删除用户组件

<!--
 * 删除用户对话框
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/2 13:43
-->
<script lang="ts" setup>
  import { useRequest } from '#imports';
  import { delUserByIdsApi } from '~/api/user';
  import { useTableSharedMeta } from '~/pages/sys/user/useTableMeta';

  const props = defineProps<{
    modelValue?: boolean;
  }>();

  // 当前弹窗是否可见
  const visible = useVModel(props, 'modelValue');

  // 共享表格数据
  const { tableRefresh, selectedKeys, tableData } = useTableSharedMeta();

  /**
   * 删除用户请求
   */
  const { run: delUserByIdsRun, loading: patchUserPasswordLoading } =
    useRequest(delUserByIdsApi, {
      onSuccess: () => {
        Message.success('删除用户成功');
        visible.value = false;

        // 清空选择keys
        selectedKeys.value = [];
        // 刷新表格数据
        tableRefresh();
      },
      onError: ({ message }) => {
        Message.error(`删除用户失败: ${message}`);
        visible.value = false;

        // 清空选择keys
        selectedKeys.value = [];
      },
    });

  /**
   * 确认删除
   */
  const onConfirm = () => {
    delUserByIdsRun(unref(selectedKeys));
  };

  /**
   * 删除用户名称
   */
  const delUserNamesContent = computed(() => {
    return tableData.value
      .filter((item) => selectedKeys.value.includes(item.userId))
      .map((item) => item.nickName)
      .join(', ');
  });
</script>

<template>
  <a-modal
    :cancel-button-props="{
      size: 'small',
    }"
    :footer="true"
    :message-type="'warning'"
    :ok-button-props="{
      size: 'small',
    }"
    :ok-loading="patchUserPasswordLoading"
    :title-align="'start'"
    :visible="visible"
    :width="300"
    class=""
    draggable
    simple
    title="删除用户"
    unmount-on-close
    @cancel="visible = false"
    @ok="onConfirm"
  >
    确定删除 "{{ delUserNamesContent }}" 吗?
  </a-modal>
</template>

<style scoped></style>

重置密码组件

<!--
 * 重置用户密码对话框
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/2 13:43
-->
<script lang="ts" setup>
  import type { FormInstance } from '@arco-design/web-vue';
  import { formRules } from '~/pages/sys/formRules';
  import { useRequest } from '#imports';
  import { patchUserPasswordApi } from '~/api/user';

  defineProps<{
    userId?: string;
    userName?: string;
  }>();

  // 窗口是否可见
  const visible = ref(false);

  // 表格
  const formRef = ref<FormInstance>();

  // 响应布局
  const { isTablet } = useLayout();

  // 表单数据
  const formData = reactive({
    password: '',
  });

  // 重置密码请求
  const { loading: patchUserPasswordLoading, run: patchUserPasswordRun } =
    useRequest(patchUserPasswordApi, {
      onSuccess: () => {
        onModalCancel();
        Message.success('重置密码成功');
      },
      onError: ({ message }) => {
        onModalCancel();
        Message.error(`重置密码失败: ${message}`);
      },
    });

  /**
   * 关闭弹窗
   */
  const onModalCancel = () => {
    visible.value = false;
    formRef.value!.resetFields();
  };

  /**
   * 重置密码按钮事件
   */
  const onResetPassword = () => {
    visible.value = true;
  };

  /**
   * 前往用户详情页面
   */
  const onToUserDetail = async (userId: string) => {
    await navigateTo(`/sys/user/${userId}`);
    // visible.value = false;
  };

  /**
   * 确认重置密码请求
   */
  const onRestPasswordConfirm = async (userId: string) => {
    const validate = await formRef.value!.validate();
    if (validate) {
      Message.warning('请填写完整信息');
      return;
    }
    //  发送请求
    patchUserPasswordRun(userId, formData.password);
  };
</script>

<template>
  <div>
    <a-button @click="onResetPassword">
      <template #icon>
        <i class="i-tabler:password" />
      </template>
    </a-button>

    <a-modal
      v-if="userId"
      :footer="true"
      :fullscreen="isTablet"
      :ok-loading="patchUserPasswordLoading"
      :title-align="'start'"
      :visible="visible"
      :width="360"
      class=""
      draggable
      modal-class="py-4! px-4! lg:px-8!"
      simple
      title="重置密码"
      unmount-on-close
      @cancel="onModalCancel"
      @ok="onRestPasswordConfirm(userId)"
    >
      <a-alert :type="'warning'" banner center class="mb-4" closable>
        重置密码将会强制使
        <a-link v-if="userId" @click="onToUserDetail(userId)">
          {{ userName }}
        </a-link>
        账号下线
      </a-alert>
      <a-form
        ref="formRef"
        :model="formData"
        :rules="formRules"
        :size="isTablet ? 'large' : 'medium'"
        layout="vertical"
      >
        <a-form-item
          :label="`重置 ${userName} 账号密码`"
          :validate-trigger="'input'"
          field="password"
          hide-asterisk
        >
          <a-input-password
            v-model="formData.password"
            placeholder="请输入新密码"
          >
            <template #prefix>
              <i class="i-tabler-lock" />
            </template>
          </a-input-password>
        </a-form-item>
      </a-form>
    </a-modal>
  </div>
</template>

<style scoped></style>

创建、更新用户组件

<!--
 * 用户更新,创建 对话框
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/2 13:53
-->
<script lang="ts" setup>
  import type { FormInstance } from '@arco-design/web-vue';
  import { useTableSharedMeta } from '../useTableMeta';
  import { SYS_GENDER } from '~/key/dict';
  import type { UserDto } from '~/types/dto/user';
  import { formRulesNullable } from '~/pages/sys/rulesNullable';
  import { formRules } from '~/pages/sys/formRules';
  import { useRequest } from '#imports';
  import { postCreateUserApi, postUserInfoApi } from '~/api/user';

  const props = defineProps<{
    visible?: boolean;
  }>();

  // 响应布局
  const { isTablet } = useLayout();

  // 共享 table 数据
  const { tableRefresh } = useTableSharedMeta();

  // 窗口是否可见
  const visible = useVModel(props, 'visible');

  // 表单
  const formRef = ref<FormInstance>();

  // 表单数据
  const formData = ref<Partial<UserDto>>({});

  /**
   * 创建用户请求
   */
  const { loading: createUserLoading, run: createUserRun } = useRequest(
    postCreateUserApi,
    {
      onSuccess: () => {
        // 创建成功 关闭窗口
        onModalCancel();

        // 提示消息
        Message.success('创建成功');

        tableRefresh();
      },
      onError: ({ message }) => {
        Message.error(`创建用户失败: ${message}`);
      },
    }
  );

  /**
   * 更新用户信息请求
   */
  const { run: updateUserInfoRun, loading: updateUserInfoLoading } = useRequest(
    postUserInfoApi,
    {
      onSuccess: () => {
        // 提示消息
        Message.success('更新用户信息成功');
        visible.value = false;

        // 刷新表格数据
        tableRefresh();
      },
      onError: ({ message }) => {
        Message.error(`更新用户信息失败: ${message}`);
      },
    }
  );
  /**
   * 新建用户
   */
  const onCreateUserSubmit = async () => {
    // 校验表单
    const validate = await formRef.value?.validate();
    if (validate) {
      Message.info({
        id: 'create-user-submit',
        content: '请完成表单填写',
      });
      return;
    }

    // 发送请求
    createUserRun({ ...unref(formData) });
  };

  /**
   * 关闭弹窗, 重置表单数据以及校验结果
   */
  const onModalCancel = () => {
    // 关闭弹窗,
    visible.value = false;

    // 重置表单数据以及校验结果
    formRef.value!.resetFields();
    formData.value = {};
  };

  /**
   * 是否是新建用户
   */
  const isCreateMode = computed<boolean>(() => {
    return !formData.value?.userId;
  });

  /**
   * 更新用户信息
   */
  const onUpdateUserSubmit = () => {
    updateUserInfoRun(formData.value?.userId as string, { ...unref(formData) });
  };

  /**
   * 加载用户信息
   *
   * @param userEntity 用户信息
   */
  const loadUserInfo = (userEntity: UserEntity) => {
    const {
      userId,
      username,
      avatarUrl,
      email,
      phoneNumber,
      addressCode,
      remark,
      nickName,
      gender,
      birthday,
    } = userEntity;

    formData.value = {
      userId,
      username,
      avatarUrl,
      email,
      phoneNumber,
      addressCode,
      remark,
      nickName,
      birthday,
      gender: gender.code,
    };

    // 显示窗口
    visible.value = true;
  };

  defineExpose({
    /**
     * 加载用户信息
     */
    loadUserInfo,
  });
</script>

<template>
  <a-modal
    :footer="true"
    :fullscreen="isTablet"
    :title="isCreateMode ? '新建用户' : '更新用户'"
    :title-align="'start'"
    :visible="visible"
    :width="680"
    draggable
    modal-class="py-6! px-6! lg:px-10!"
    simple
    @cancel="onModalCancel"
  >
    <!--    {{ form }}-->
    <a-form
      ref="formRef"
      :layout="'vertical'"
      :model="formData"
      :rules="formRulesNullable"
    >
      <div class="overflow-y-auto box-border overflow-x-hidden">
        <a-row :gutter="isTablet ? 0 : 24">
          <a-col :span="isTablet ? 24 : 12">
            <a-form-item
              class="relative"
              extra="仅支持 image/png, image/jpeg 格式"
              field="avatarUrl"
              label="账号头像"
            >
              <i
                class="i-banner-undraw_personal_info_re_ur1n w-[12rem] h-[6rem]"
              />

              <UploadAvatar
                v-model="formData.avatarUrl"
                action-url="http://localhost:7000/my/upload/avatar"
                class="absolute right-6"
              />
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item
              :field="isCreateMode ? 'username' : ''"
              :hide-asterisk="!isCreateMode"
              :rules="formRules.username"
              label="登录账号"
            >
              <a-input
                v-model="formData.username"
                :disabled="!isCreateMode"
                placeholder="用户登录账号"
              >
                <template #prefix>
                  <i class="i-tabler:user-bolt" />
                </template>
              </a-input>
            </a-form-item>

            <a-form-item
              :field="isCreateMode ? 'password' : ''"
              :hide-asterisk="!isCreateMode"
              :rules="formRules.password"
              label="用户密码"
            >
              <a-input-password
                v-model="formData.password"
                :disabled="!isCreateMode"
                :placeholder="isCreateMode ? '用户密码' : '******'"
              >
                <template #prefix>
                  <i class="i-tabler:lock" />
                </template>
              </a-input-password>
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item field="nickName" hide-asterisk label="用户名称">
              <a-input v-model="formData.nickName" placeholder="用户名称">
                <template #prefix>
                  <i class="i-tabler:pencil-check" />
                </template>
              </a-input>
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item field="birthday" label="出生日期">
              <a-date-picker
                v-model="formData.birthday"
                :disabled-date="
                  (current: Date | undefined) =>
                    $dayjs(current).isAfter($dayjs())
                "
                :show-now-btn="false"
                class="w-full"
                placeholder="请输入出生日期"
              >
                <template #prefix>
                  <i class="i-tabler:calendar" />
                </template>

                <template #suffix-icon>
                  <span />
                </template>
              </a-date-picker>
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item field="email" hide-asterisk label="用户邮件">
              <EmailInput v-model="formData.email" />
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item field="gender" label="性别">
              <SysDictSelect
                v-model="formData.gender"
                :dict-type="SYS_GENDER"
                all
                other-name="未知"
                placeholder="请选择性别"
              />
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item field="addressCode" label="所在地区">
              <AreaCascader
                v-model="formData.addressCode"
                placeholder="请选择所在地区"
              />
            </a-form-item>

            <a-form-item field="phoneNumber" label="手机号">
              <a-input
                v-model="formData.phoneNumber"
                placeholder="请输入手机号码"
              >
                <template #prefix>
                  <i class="i-tabler:phone" />
                </template>
              </a-input>
            </a-form-item>
          </a-col>

          <a-col :span="isTablet ? 24 : 12">
            <a-form-item field="remark" label="个人简介">
              <a-textarea
                v-model="formData.remark"
                :auto-size="{
                  minRows: 4,
                  maxRows: 4,
                }"
                :max-length="120"
                placeholder="请输入个人简介"
                show-word-limit
              />
            </a-form-item>
          </a-col>
        </a-row>
      </div>
    </a-form>

    <template #footer>
      <a-space class="w-full justify-end pt-4">
        <a-space>
          <a-button @click="onModalCancel">取消</a-button>

          <a-button v-if="isCreateMode" @click="formRef?.resetFields()">
            重置表单
          </a-button>

          <a-button
            v-if="isCreateMode"
            :loading="createUserLoading"
            :type="'primary'"
            @click="onCreateUserSubmit"
          >
            <span>创建用户</span>
            <template #icon>
              <i class="i-tabler:users-plus" />
            </template>
          </a-button>

          <a-button
            v-else
            :loading="updateUserInfoLoading"
            :type="'primary'"
            @click="onUpdateUserSubmit"
          >
            <span>更新用户</span>
            <template #icon>
              <i class="i-tabler:users-plus" />
            </template>
          </a-button>
        </a-space>
      </a-space>
    </template>
  </a-modal>
</template>

<style lang="scss" scoped>
  :deep(.custom-upload-avatar img),
  :deep(.arco-upload-picture-card),
  :deep(.arco-upload-list-picture-mask) {
    @apply rounded-full;
  }
</style>

查询组件

<!--
 * 搜索用户表单
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/7 13:52
-->
<script lang="ts" setup>
  import type { FormInstance } from '@arco-design/web-vue';
  import { useTableSharedMeta } from '../useTableMeta';
  import type { UserQuery } from '~/types/query/user';
  import { SYS_GENDER, SYS_STATUS } from '~/key/dict';

  const { isDesktop, isTablet } = useLayout();
  const { loadTableRun, tableLoading } = useTableSharedMeta();

  const formRef = ref<FormInstance>();

  const formData = ref<Partial<UserQuery>>({});

  const activeKey = ref<string[]>([]);

  /**
   * 触发查询
   */
  const onSearch = () => {
    loadTableRun({ ...unref(formData) });
  };

  /**
   * 折叠/展开
   */
  const onCollapse = () => {
    if (activeKey.value.length > 0) {
      activeKey.value = [];
      return;
    }
    activeKey.value = ['1'];
  };
</script>

<template>
  <a-collapse
    :active-key="activeKey"
    :bordered="false"
    :show-expand-icon="false"
  >
    <a-collapse-item key="1">
      <template #header>
        <a-space class="cursor-pointer" @click="onCollapse">
          <i class="i-tabler:list-search text-1.1rem" />
          <span>用户查询</span>
        </a-space>
      </template>

      <template #extra>
        <a-space>
          <a-button
            :class="{ 'arco-btn-only-icon': !isDesktop }"
            :loading="tableLoading"
            :shape="'round'"
            :size="'small'"
            type="primary"
            @click.stop="onSearch"
          >
            <span v-if="isDesktop">搜索用户</span>
            <template #icon>
              <i class="i-tabler:input-search" />
            </template>
          </a-button>

          <a-button
            :class="{ 'arco-btn-only-icon': !isDesktop }"
            :shape="'round'"
            :size="'small'"
            @click.stop="formRef?.resetFields() || onSearch()"
          >
            <span v-if="isDesktop">重置表单</span>
            <template #icon>
              <i class="i-tabler:refresh" />
            </template>
          </a-button>
        </a-space>
      </template>

      <a-form
        ref="formRef"
        :label-col-props="{ span: 6 }"
        :layout="isTablet ? 'vertical' : 'horizontal'"
        :model="formData"
        :wrapper-col-props="{ span: 18 }"
      >
        <a-row :gutter="15">
          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="username" label="用户账号">
              <a-input
                v-model="formData.username"
                placeholder="输入查询用户账号"
              />
            </a-form-item>
          </a-col>
          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="nickName" label="用户名称">
              <a-input
                v-model="formData.nickName"
                placeholder="输入查询用户名称"
              />
            </a-form-item>
          </a-col>

          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="gender" label="用户性别">
              <SysDictSelect
                v-model="formData.gender"
                :dict-type="SYS_GENDER"
                all
                placeholder="输入查询用户性别"
              />
            </a-form-item>
          </a-col>
          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="email" label="用户邮箱">
              <EmailInput
                v-model="formData.email"
                placeholder="输入查询用户邮箱"
              />
            </a-form-item>
          </a-col>
          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="addressCode" label="所在地区">
              <AreaCascader
                v-model="formData.addressCode"
                placeholder="输入查询用户所在地区"
              />
            </a-form-item>
          </a-col>
          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="isStatus" label="用户状态">
              <SysDictSelect
                v-model="formData.isStatus"
                :dict-type="SYS_STATUS"
                all
                placeholder="输入查询用户状态"
              />
            </a-form-item>
          </a-col>

          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="startTime" label="创建日期">
              <a-date-picker
                v-model="formData.startTime"
                class="w-full"
                format="YYYY-MM-DD"
                placeholder="输入查询开始日期"
              />
            </a-form-item>
          </a-col>

          <a-col :lg="8" :md="12" :sm="24" :xl="6">
            <a-form-item field="endTime" label="结束日期">
              <a-date-picker
                v-model="formData.endTime"
                class="w-full"
                format="YYYY-MM-DD"
                placeholder="输入查询结束日期"
              />
            </a-form-item>
          </a-col>
        </a-row>
      </a-form>
    </a-collapse-item>
  </a-collapse>
</template>

<style lang="scss" scoped>
  // 展开容器-头部
  :deep(.arco-collapse-item-header-left) {
    @apply mb-1.5 pl-2 cursor-default;
  }

  // 展开容器-内容
  :deep(.arco-collapse-item-content) {
    background-color: var(--color-bg-2);
    @apply px-1 lg:px-2;
  }

  // 小屏 单列为撑满
  :deep(.arco-col) {
    width: 100%;
  }
</style>