项目导航站开发笔记之前端实现

一、创建通用部分组件

1. 布局

  • layouts目录下创建布局BasicLayout.vue

    • 使用 Ant Design 组件库的 Layout 组件,我们选用第一种
<template>
  <div id="basicLayout">
    <a-layout style="min-height: 100vh">
      <a-layout-header>Header</a-layout-header>
      <a-layout-content>Content</a-layout-content>
      <a-layout-footer>Footer</a-layout-footer>
    </a-layout>
  </div>
</template>

<script setup lang="ts"></script>
  • 在App.vue中引入
<template>
  <div id="app">
    <BasicLayout />
  </div>
</template>

<script setup lang="ts">
import BasicLayout from "@/layouts/BasicLayout.vue";
</script>

2. 底部栏

  • 通常用于展示备案信息
<a-layout-footer class="footer">
        <p class="footer-entry">
          <span class="miit">
            <img src="/gov.png" title="中华人民共和国工业和信息化部" style="margin-right: 5px; width: 18px; height: 18px;" />
            <a target="_blank" rel="noopener" href="http://beian.miit.gov.cn/">闽ICP备xxxx号</a>
          </span>
          ©2023-2025 QiXiaoRanTuT
        </p>
      </a-layout-footer>
  • 样式
#BasicLayout .footer {
  text-align: center;
  background-color: #efefef;
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 16px;
}

3. 内容

  • 项目通过 Vue Router 实现路由管理,在 router/index.ts 中配置不同路径,可根据地址加载并渲染对应页面。

  • 修改BasicLayout内容部分的代码

<a-layout-content class="content">
  <router-view />
</a-layout-content>
  • 样式
#BasicLayout .content {
  padding: 20px 0;
  min-height: calc(100vh - 120px);
  background: #ffffff;
}

4. 顶部栏

直接基于 Ant Design 的菜单组件封装 GlobalHeader 全局顶部栏,并统一放置于 components 目录中,初期可直接复制官方示例代码进行使用。

  • 引入顶部栏组件
<a-layout-header class="header">
  <GlobalHeader />
</a-layout-header>
  • 以及对应导入语句
<script setup lang="ts">
import GlobalHeader from "@/components/GlobalHeader.vue";
</script>
  • 顺便修改一下全局header的样式
#BasicLayout .header {
  background-color: #fff;
  margin-bottom: 16px;
  color: unset;
  padding-inline: 20px;
}
  • 在Components文件夹中创建``GlobalHeader.vue`组件,完善需求
    • 这里登录相关的``loginUserStore`部分由路由的状态管理完成
<div id="globalHeader">
   <div class="title-bar">
          <img
            class="logo"
            src="https://qixiaoran-bucket.oss-cn-fuzhou.aliyuncs.com/logo.png"
            alt="logo"
          />
          <div class="title">柒小染导航系统</div>
   </div>
  <a-menu
  v-model:selectedKeys="current"
  mode="horizontal"
  :items="items"
  @click="doMenuClick"
/>
  <div class="user-login-status">
          <div v-if="loginUserStore.loginUser.id" class="user-info">
            <a-avatar :src="loginUserStore.loginUser.avatarUrl" size="large" />
            <span class="user-name">{{ loginUserStore.loginUser.userName }}</span>
            <a-button type="primary" @click="logout" size="small">注销</a-button>
          </div>
          <div v-else class="login-btn">
            <a-button type="primary" href="/user/login">登录</a-button>
          </div>
      </div>
</div>
  • 根据各自需求修改菜单配置,详细配置在路由部分
import { h, ref } from "vue";
import { CrownOutlined, HomeOutlined } from "@ant-design/icons-vue";
import { MenuProps } from "ant-design-vue";

const current = ref<string[]>(["home"]);
// 从路由配置生成菜单项
const items = ref<MenuProps["items"]>([
  ...routes
    .filter((route) => !route.hideInMenu)
    .map((route) => ({
      key: route.path,
      icon: route.icon,
      label: route.label,
      title: route.label,
    })),
  // 保留更多菜单项
  {
    key: "others",
    label: h(
      "a",
      {
        href: "https://www.baidu.com",
        target: "_blank",
      },
      "更多"
    ),
    title: "更多",
  },
]);

二、路由与状态管理

1. 路由配置

  • src/router/index.ts 配置主路由,以及抽离普通路由项:
// 从 MenuRoute 提取需要的路由属性
const routeRecords: RouteRecordRaw[] = routes.map((route) => ({
  path: route.path,
  name: route.name,
  component: route.component,
  meta: route.meta,
}));

const router = createRouter({
  history: createWebHistory(),
  routes: [
    ...routeRecords,
    {
      path: "/:pathMatch(.*)*",
      name: "NotFound",
      component: () => import("@/views/NotFoundPage.vue"),
    },
  ],
});

export default router;
  • src/router/routes.ts
export interface MenuRoute {
  path: string;
  name: string;
  component: Component;
  label: string;
  icon?: () => any;
  hideInMenu?: boolean;
  meta?: {
    requireAuth?: boolean; // 需要登录
    requireAdmin?: boolean; // 需要管理员权限
  };
}

export const routes: MenuRoute[] = [
  {
    path: "/",
    name: "home",
    component: HomePage,
    label: "主页",
    icon: () => h(HomeOutlined),
    meta: {
      requireAuth: true
    }
  },
  {
    path: "/user/login",
    name: "userLogin",
    component: UserLoginPage,
    label: "用户登录",
  },
  {
    path: "/user/register",
    name: "userRegister",
    component: UserRegisterPage,
    label: "用户注册",
  },
  {
    path: "/admin/userManage",
    name: "userManage",
    component: UserManagePage,
    label: "用户管理",
    icon: () => h(CrownOutlined),
    meta: {
      requireAuth: true,
      requireAdmin: true
    }
  },
]; 
  • 给GlobalHeader补充高亮以及各种事件
import { h, ref } from "vue";
import { MenuProps } from "ant-design-vue";
import { useRouter } from "vue-router";
import { useLoginUserStore } from "@/store/useLoginUserStore";
import { routes } from "@/router/routes";
import { userLogout } from "@/api/user";
const loginUserStore = useLoginUserStore();
const router = useRouter();

// 点击菜单后的路由跳转事件
const doMenuClick = ({ key }: { key: string }) => {
  router.push({
    path: key,
  });
};
// 当前选中菜单
const current = ref<string[]>(["mail"]);
// 监听路由变化,更新当前选中菜单
router.afterEach((to) => {
  current.value = [to.path];
});

// 从路由配置生成菜单项
const items = ref<MenuProps["items"]>([
  ...routes
    .filter((route) => !route.hideInMenu)
    .map((route) => ({
      key: route.path,
      icon: route.icon,
      label: route.label,
      title: route.label,
    })),
  // 保留更多菜单项
  {
    key: "others",
    label: h(
      "a",
      {
        href: "https://www.baidu.com",
        target: "_blank",
      },
      "更多"
    ),
    title: "更多",
  },
]);

const logout = () => {
  userLogout().then(() => {
    loginUserStore.loginUser = {};
    router.push("/user/login");
  });
};

2.接口封装与状态管理

  • 为统一管理接口请求地址等配置,可参考 Axios 官方文档,在项目中创建 request.ts 文件,封装全局请求逻辑,包括:基础请求地址、超时时间设置、自定义请求和响应拦截器等
  • 其中 withCredentials: true 一定要写,否则无法在发请求时携带 Cookie,就无法完成登录
import axios from "axios";
import { useRouter } from 'vue-router';

const myAxios = axios.create({
  baseURL: process.env.NODE_ENV === "development" ? "http://localhost:7002" : "https://user.api.onavi.icu",
  timeout: 10000,
  withCredentials: true,
});

const router = useRouter();

// Add a request interceptor
myAxios.interceptors.request.use(
  function (config) {
    // Do something before request is sent
    return config;
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error);
  }
);

// Add a response interceptor
myAxios.interceptors.response.use(
  function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    console.log(response);

    const { data } = response;
    console.log(data);
    // 未登录
    if (data.code === 40100) {
      // 不是获取用户信息接口,或者不是登录页面,则跳转到登录页面
      if (
        !response.request.responseURL.includes("user/current") &&
        !window.location.pathname.includes("/user/login")
      ) {
        router.push({ path: '/user/login', query: { redirect: window.location.pathname } });
      }
    }
    return response;
  },
  function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    return Promise.reject(error);
  }
);

export default myAxios;
  • API 请求模块, src/apis/user.ts
import myAxios from "@/request";

/**
 * 用户注册
 * @param params
 */
export const userRegister = async (params: any) => {
  return myAxios.request({
    url: "/api/user/register",
    method: "POST",
    data: params,
  });
};

/**
 * 用户登录
 * @param params
 */
export const userLogin = async (params: any) => {
  return myAxios.request({
    url: "/api/user/login",
    method: "POST",
    data: params,
  });
};

/**
 * 用户注销
 * @param params
 */
export const userLogout = async (params: any) => {
  return myAxios.request({
    url: "/api/user/logout",
    method: "POST",
    data: params,
  });
};

/**
 * 获取当前用户
 */
export const getCurrentUser = async () => {
  return myAxios.request({
    url: "/api/user/current",
    method: "GET",
  });
};

/**
 * 获取用户列表
 * @param userAccount
 */
export const searchUsers = async (userName: any) => {
  return myAxios.request({
    url: "/api/user/search",
    method: "GET",
    params: {
      userName,
    },
  });
};

export const editUser = async (params: any) => {
  return myAxios.request({
    url: "/api/user/edit",
    method: "PUT",
    data: params,
  });
};

/**
 * 删除用户
 * @param id
 */
export const deleteUser = async (id: string) => {
  return myAxios.request({
    url: "/api/user/delete",
    method: "POST",
    data: id,
    // 关键点:要传递 JSON 格式的值
    headers: {
      "Content-Type": "application/json",
    },
  });
};

/**
 * 上传文件
 * @param file 文件对象
 */
export const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);
  return myAxios.request({
    url: "/api/user/upload",
    method: "POST",
    data: formData,
  });
};

2.1 全局状态管理

  • 什么是全局状态管理?
    全局状态管理是指将一些在多个页面间共享使用的数据集中管理,而不是仅限于单个页面或组件内部维护。

  • 适合做全局状态的数据包括:
    例如已登录的用户信息,这类数据在整个应用中多个页面都会频繁使用,适合统一存储和调用。

  • Pinia 是目前主流的 Vue 状态管理库,相比 Vuex 更加轻量、易用,推荐参考其官方入门文档进行学习和使用。

  • 在main.ts中引入Pinia

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { createPinia } from "pinia";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";

const pinia = createPinia();
createApp(App).use(pinia).use(Antd).use(router).mount("#app");
  • src/store目录下定义useLoginUserStore.ts模块,定义了用户的存储、远程获取、修改逻辑
import { getCurrentUser } from "@/api/user";
import { defineStore } from "pinia";
import { ref } from "vue";

export const useLoginUserStore = defineStore("loginUser", () => {
  const loginUser = ref({
    id: "",
    userRole: 0,
    userName: "未登录",
    userAccount: "",
    avatarUrl: "",
  });

  const initialized = ref(false);

  //远程获取登录用户信息
  async function fetchLoginUser() {
    try {
      const res = await getCurrentUser();
      if (res.data.code === 0) {
        loginUser.value = res.data.data;
      }
      initialized.value = true;
    } catch (error) {
      initialized.value = true;
      throw error;
    }
  }
  //单独设置登录用户信息
  function setLoginUser(newLoginUser: any) {
    loginUser.value = newLoginUser;
  }
  return { loginUser, fetchLoginUser, setLoginUser, initialized };
});
  • 使用状态管理
    • 在用户首次访问页面时就尝试获取登录用户信息,修改``App.vue`
<script setup lang="ts">
import BasicLayout from '@/layouts/BasicLayout.vue';
import { useLoginUserStore } from "@/store/useLoginUserStore";

const loginUserStore = useLoginUserStore();
loginUserStore.fetchLoginUser();
</script>
  • 在GlobalHeader.vue中引入全局状态,完成用户显示功能
import { useLoginUserStore } from "@/store/useLoginUserStore";

三、前端页面开发

1. 首页开发://TODO 项目导航页

  • 新建 src/pages 目录,用于存放所有的页面文件。
  • 每次新建页面时,需要在 router/index.ts 中配置路由,比如欢迎页的路由为:
const routes: Array<RouteRecordRaw> = [
  {
    path: "/",
    name: "home",
    component: HomeView,
  },
  ...
]
  • 目录结构如下:
  • HomePage.vue页面代码初始化
<template>
  <div id="homePage">
    <h1>{{ msg }}</h1>
  </div>
</template>

<script setup lang="ts">
const msg = "欢迎来到项目导航,技术学习永无止境~";
</script>

<style scoped>
#homePage {
}
</style>

2. 登录页:表单验证、状态绑定、接口调用、跳转逻辑

<template>
  <div id="userLoginPage">
    <h1 class="title">用户登录</h1>
    <a-form style="width: 500px; margin: 0 auto" :model="formState" name="basic" label-align="left"
      :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" autocomplete="off" @finish="handleSubmit">
      <a-form-item label="账号" name="userAccount" :rules="[{ required: true, message: '请输入账号!' }]">
        <a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
      </a-form-item>

      <a-form-item label="密码" name="userPassword" :rules="[
        { required: true, message: '请输入密码!' },
        { min: 8, message: '密码长度至少为8位' },
      ]">
        <a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
      </a-form-item>

      <a-form-item :wrapper-col="{ offset: 10, span: 20 }">
        <a-button type="primary" html-type="submit">登录</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>
<script lang="ts" setup>
import { reactive } from "vue";
import { useRouter } from "vue-router";
import { userLogin } from "@/api/user";
import { message } from "ant-design-vue";
import { useLoginUserStore } from "@/store/useLoginUserStore";

const router = useRouter();
interface FormState {
  userAccount: string;
  userPassword: string;
}
const loginUserStore = useLoginUserStore();
const formState = reactive<FormState>({
  userAccount: "",
  userPassword: "",
});
const handleSubmit = async (values: any) => {
  try {
    const res = await userLogin(values);
    if (res.data.code === 0) {
      await loginUserStore.fetchLoginUser();
      message.success("登录成功");

      // 处理重定向逻辑
      const redirect = router.currentRoute.value.query.redirect?.toString() || "/";
      router.push({
        path: redirect,
        replace: true,
      });
    } else {
      message.error(`登录失败: ${res.data.message}`);
    }
  } catch (error) {
    message.error("请求异常,请检查网络");
    console.error("登录请求异常:", error);
  }
};

</script>
<style scoped>
#userLoginPage .title {
  text-align: center;
  margin-bottom: 20px;
}
</style>

3. 注册页:字段校验、调用注册 API、反馈提示

  • 注册页面同样使用表单组件,在登录页的基础上扩展更多表单项即可
<template>
  <div id="userRegisterPage">
    <h1 class="title">用户注册</h1>
    <a-form style="width: 500px; margin: 0 auto" :model="formState" name="basic" label-align="left"
      :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }" autocomplete="off" @finish="handleSubmit">
      <a-form-item label="账号" name="userAccount" :rules="[{ required: true, message: '请输入账号!' }]">
        <a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
      </a-form-item>
      <a-form-item label="用户名" name="userName" :rules="[{ required: true, message: '请输入用户名!' }]">
        <a-input v-model:value="formState.userName" placeholder="请输入用户名" />
      </a-form-item>

      <a-form-item label="密码" name="userPassword" :rules="[
        { required: true, message: '请输入密码!' },
        { min: 8, message: '密码长度至少为8位' },
      ]">
        <a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
      </a-form-item>

      <a-form-item label="确认密码" name="checkPassword" :rules="[
        { required: true, message: '请输入密码!' },
        { min: 8, message: '密码长度至少为8位' },
      ]">
        <a-input-password v-model:value="formState.checkPassword" placeholder="请确认密码" />
      </a-form-item>
      <a-form-item :wrapper-col="{ offset: 10, span: 20 }">
        <a-button type="primary" html-type="submit">注册</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>

<script setup lang="ts">
import { reactive } from "vue";
import { useRouter } from "vue-router";
import { userRegister } from "@/api/user";
import { message } from "ant-design-vue";
import { useLoginUserStore } from "@/store/useLoginUserStore";

const router = useRouter();
interface FormState {
  userName: string;
  userAccount: string;
  userPassword: string;
  checkPassword: string;
}
const loginUserStore = useLoginUserStore();
const formState = reactive<FormState>({
  userName: "",
  userAccount: "",
  userPassword: "",
  checkPassword: "",
});
const handleSubmit = async (values: FormState) => {
  if (values.userPassword !== values.checkPassword) {
    message.error("两次输入的密码不一致");
    return;
  }

  const res = await userRegister({
    userName: values.userName,
    userAccount: values.userAccount,
    userPassword: values.userPassword,
    checkPassword: values.checkPassword,
  });

  if (res.data.data && res.data.code === 0) {
    message.success("注册成功");
    router.push({
      path: "/user/login",
      replace: true,
    });
  } else {
    message.error("注册失败" + res.data.description);
  }
};
</script>

<style scoped>
#userRegisterPage .title {
  text-align: center;
  margin-bottom: 20px;
}
</style>

4. 用户管理页:使用 AntD 的 a-table 展示用户列表,结合分页、搜索与删除动作

  • 需求说明:
    管理员需具备查看已注册用户信息的权限,并能根据用户名进行搜索及删除非法用户。

    为确保数据安全,需限制普通用户访问该页面,因此必须实现权限控制。整体可分为两个步骤:页面开发与权限控制。

4.1 页面开发

页面结构包括:顶部搜索栏 + 底部用户信息表格。

使用 Ant Design 的表格组件展示全部用户信息,实现基础的数据列表展示功能。

<template>
  <div class="userManagePage">
    <a-input-search v-model:value="searchValue" placeholder="请输入账号" enter-button="搜索" size="large" @search="onSearch" />
    <a-table :columns="columns" :data-source="data">
      <template #bodyCell="{ column, record }">
        <template v-if="column.dataIndex === 'avatarUrl'">
          <a-image :src="record.avatarUrl" :width="120" />
        </template>
        <template v-else-if="column.dataIndex === 'userRole'">
          <a-tag :color="record.userRole === 1 ? 'green' : 'blue'">
            {{ record.userRole === 1 ? "管理员" : "普通用户" }}
          </a-tag>
        </template>
        <template v-else-if="column.dataIndex === 'createTime'">
          {{ dayjs(record.createTime).format("YYYY-MM-DD HH:mm:ss") }}
        </template>
        <template v-else-if="column.dataIndex === 'gender'">
          {{ record.gender === 1 ? "男" : "女" }}
        </template>
        <template v-else-if="column.dataIndex === 'action'">
          <a-button type="primary" class="action-btn edit-btn" @click="handleEdit(record.id)">
            编辑
          </a-button>
          <a-button type="primary" danger class="action-btn delete-btn" @click="handleDelete(record.id)">
            删除
          </a-button>
        </template>
      </template>
    </a-table>

    <!-- 添加编辑用户的弹窗 -->
    <a-modal v-model:visible="editModalVisible" title="编辑用户" @ok="handleEditSubmit" @cancel="handleEditCancel">
      <a-form ref="editFormRef" :model="editForm" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
        <a-form-item label="头像" name="avatarUrl">
          <div class="avatar-uploader">
            <a-upload v-model:file-list="fileList" name="file" list-type="picture-card" class="avatar-uploader"
              :show-upload-list="false" :customRequest="customUpload" :before-upload="beforeUpload"
              @change="handleChange">
              <img v-if="editForm.avatarUrl" :src="editForm.avatarUrl" alt="avatar" class="avatar" />
              <div v-else>
                <loading-outlined v-if="uploading"></loading-outlined>
                <plus-outlined v-else></plus-outlined>
                <div class="ant-upload-text">上传</div>
              </div>
            </a-upload>
          </div>
        </a-form-item>
        <a-form-item label="账号" name="userAccount">
          <a-input v-model:value="editForm.userAccount" disabled />
        </a-form-item>
        <a-form-item label="用户名" name="userName" :rules="[{ required: true, message: '请输入用户名!' }]">
          <a-input v-model:value="editForm.userName" />
        </a-form-item>
        <a-form-item label="性别" name="gender">
          <a-select v-model:value="editForm.gender">
            <a-select-option :value="1">男</a-select-option>
            <a-select-option :value="0">女</a-select-option>
          </a-select>
        </a-form-item>
        <a-form-item label="用户角色" name="userRole">
          <a-select v-model:value="editForm.userRole">
            <a-select-option :value="1">管理员</a-select-option>
            <a-select-option :value="0">普通用户</a-select-option>
          </a-select>
        </a-form-item>
      </a-form>
    </a-modal>
  </div>
</template>
<script lang="ts" setup>
import { SmileOutlined, DownOutlined, LoadingOutlined, PlusOutlined } from "@ant-design/icons-vue";
import { searchUsers } from "@/api/user";
import { message } from "ant-design-vue";
import { ref, reactive } from "vue";
import dayjs from "dayjs";
import { useLoginUserStore } from "@/store/useLoginUserStore";
import { deleteUser, editUser, uploadFile } from "@/api/user";
import type { UploadChangeParam, UploadProps } from 'ant-design-vue';

// const username = ref("");
const columns = [
  {
    title: "id",
    dataIndex: "id",
  },
  {
    title: "用户名",
    dataIndex: "userName",
  },
  {
    title: "账号",
    dataIndex: "userAccount",
  },
  {
    title: "头像",
    dataIndex: "avatarUrl",
  },
  {
    title: "性别",
    dataIndex: "gender",
  },
  {
    title: "注册时间",
    dataIndex: "createTime",
  },
  {
    title: "用户角色",
    dataIndex: "userRole",
  },
  {
    title: "操作",
    dataIndex: "action",
  },
];

const data = ref<any[]>([]);

const searchValue = ref("");
const onSearch = () => {
  fetchUserList(searchValue.value);
};

const handleDelete = async (id: string) => {
  if (!id) {
    message.error("请选择要删除的用户");
    return;
  }
  if (id === '1') {
    message.error("不能删除管理员");
    return;
  }
  const res = await deleteUser(id);
  if (res.data.code === 0) {
    message.success("删除成功");
    fetchUserList();
  } else {
    message.error("删除失败");
  }
};

const fetchUserList = async (userAccount = "") => {
  const res = await searchUsers(userAccount);
  if (res.data.data) {
    data.value = res.data.data;
  } else {
    message.error("获取用户列表失败");
  }
};
fetchUserList();

// 编辑表单相关
const editModalVisible = ref(false);
const editFormRef = ref();
const editForm = reactive({
  id: '',
  userAccount: '',
  userName: '',
  gender: 1,
  userRole: 0,
  avatarUrl: '',
});

// 上传相关
const fileList = ref([]);
const uploading = ref(false);

// 自定义上传方法
const customUpload = async (options: any) => {
  const { file, onSuccess, onError } = options;
  try {
    const res = await uploadFile(file);
    if (res.data.code === 0) {
      onSuccess(res.data);
    } else {
      onError(new Error(res.data.message || '上传失败'));
    }
  } catch (error) {
    onError(error);
  }
};

// 上传前校验
const beforeUpload = (file: File) => {
  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
  if (!isJpgOrPng) {
    message.error('只能上传 JPG/PNG 格式的图片!');
    return false;
  }
  const isLt2M = file.size / 1024 / 1024 < 2;
  if (!isLt2M) {
    message.error('图片必须小于 2MB!');
    return false;
  }
  return true;
};

// 处理上传状态改变
const handleChange = (info: UploadChangeParam) => {
  if (info.file.status === 'uploading') {
    uploading.value = true;
    return;
  }
  if (info.file.status === 'done') {
    uploading.value = false;
    const response = info.file.response;
    if (response.code === 0) {
      editForm.avatarUrl = response.data;
      message.success('上传成功');
    } else {
      message.error('上传失败:' + (response.message || response.description));
    }
  }
  if (info.file.status === 'error') {
    uploading.value = false;
    message.error('上传失败,请重试');
  }
};

// 点击编辑按钮
const handleEdit = (id: string) => {
  // 根据 id 找到对应的用户数据
  const user = data.value.find(item => item.id === id);
  if (user) {
    // 回写表单数据
    editForm.id = user.id;
    editForm.userAccount = user.userAccount;
    editForm.userName = user.userName;
    editForm.avatarUrl = user.avatarUrl;
    editForm.gender = user.gender;
    editForm.userRole = user.userRole;
    // 显示弹窗
    editModalVisible.value = true;
  }
};

// 提交编辑
const handleEditSubmit = async () => {
  try {
    const res = await editUser({
      id: editForm.id,
      userName: editForm.userName,
      gender: editForm.gender,
      userRole: editForm.userRole,
      avatarUrl: editForm.avatarUrl,
    });

    if (res.data.code === 0) {
      message.success('编辑成功');
      editModalVisible.value = false;
      // 刷新用户列表
      fetchUserList();
    } else {
      message.error('编辑失败:' + res.data.description);
    }
  } catch (error) {
    message.error('编辑失败:' + (error as Error).message);
  }
};

// 取消编辑
const handleEditCancel = () => {
  editModalVisible.value = false;
};

</script>

<style scoped>
.userManagePage {
  padding: 16px;
}

.avatar-uploader {
  text-align: center;
}

.avatar-uploader .ant-upload {
  width: 128px;
  height: 128px;
  margin-right: 0;
}

.avatar {
  width: 128px;
  height: 128px;
  object-fit: cover;
}

.ant-upload-text {
  margin-top: 8px;
  color: #666;
}

/* 优化后的搜索框样式 */
.userManagePage :deep(.ant-input-search) {
  max-width: 400px;
  margin: 0px auto 10px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

/* 输入框样式 */
.userManagePage :deep(.ant-input) {
  height: 36px;
  border-radius: 18px 0 0 18px;
  padding: 0 16px;
}

/* 搜索按钮样式 */
.userManagePage :deep(.ant-input-search-button) {
  height: 36px;
  border-radius: 0 18px 18px 0;
  padding: 0 20px;
}

/* 悬停效果 */
.userManagePage :deep(.ant-input-search-button:hover) {
  background: #40a9ff;
  border-color: #40a9ff;
}

/* 聚焦效果 */
.userManagePage :deep(.ant-input:focus) {
  border-color: #40a9ff;
  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}

/* 表格间距优化 */
.userManagePage :deep(.ant-table) {
  margin-top: 12px;
}

/* 操作按钮容器 */
.userManagePage :deep(.ant-table-cell:last-child) {
  padding: 8px 0 !important;
}

/* 合并按钮基础样式 */
.userManagePage :deep(.action-btn) {
  margin: 0 4px;
  padding: 4px 12px;
  border-radius: 4px;
  transition: all 0.2s ease;
  min-width: 80px;
  text-align: center;
  display: inline-flex;
  justify-content: center;
  align-items: center;
}

/* 合并按钮颜色定义 */
.userManagePage :deep(.edit-btn) {
  background: #1890ff;
  border-color: #1890ff;
  color: white !important;
}

.userManagePage :deep(.delete-btn) {
  background: #ff4d4f;
  border-color: #ff4d4f;
  color: white !important;
}

/* 合并悬停效果 */
.userManagePage :deep(.edit-btn:hover),
.userManagePage :deep(.delete-btn:hover) {
  opacity: 0.9;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* 统一按钮状态 */
.userManagePage :deep(.ant-btn) {
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
  • 补充:
    • 因为数据量不会太大,所有数据一次性传给dataSource,表格会自动分页显示,暂且直接使用前端进行分页,如有需求可以参考以下代码进行后端分页:
<a-table
  :columns="columns"
  :data-source="data"
  :pagination="pagination"
  @change="handleTableChange"
/>
const pagination = reactive({
  current: 1,
  pageSize: 10,
  total: 0,
  showTotal: (total: number) => `共 ${total} 条`,
});
const handleTableChange = (pagination) => {
  // 这里拿到分页参数,去请求后端
  fetchUserList(pagination.current, pagination.pageSize);
};

4.2 权限控制

  • 实现权限控制通常有两种方式:单页面控制全局控制

  • 核心思路:都是在用户进入页面前判断其是否拥有访问权限。区别仅在于权限校验代码是写在每个页面内,还是集中封装在一个独立模块中

  • 推荐做法:使用全局权限控制,这样可以借助 Vue Router 的路由守卫机制,在每次路由切换前统一进行权限判断,避免重复逻辑

  • 具体做法:src 目录下创建 access.ts 文件,自定义权限判断逻辑,例如通过页面路径前缀判断用户是否具备访问权限

import router from "@/router";
import { useLoginUserStore } from "@/store/useLoginUserStore";
import { message } from "ant-design-vue";


/* 
    全局权限控制
*/
router.beforeEach(async (to, from, next) => {
    const loginUserStore = useLoginUserStore();
    
    // 如果用户信息未初始化,先尝试获取
    if (!loginUserStore.loginUser.id) {
        try {
            await loginUserStore.fetchLoginUser();
        } catch (error) {
            console.error('获取用户信息失败:', error);
        }
    }

    const loginUser = loginUserStore.loginUser;

    // 需要登录的页面(基于meta配置)
    if (to.meta?.requireAuth) {
        if (!loginUser.id) {
            message.warning("请先登录");
            return next({
                path: "/user/login",
                query: { redirect: to.fullPath }
            });
        }

        // 需要管理员权限的页面
        if (to.meta?.requireAdmin && loginUser.userRole !== 1) {
            message.error("无权限访问");
            return next(from.fullPath);
        }
    }

    // 保留原有路径检查(可选,可逐步迁移到meta方式)
    if (to.path.startsWith("/admin") && (!loginUser.id || loginUser.userRole !== 1)) {
        message.error("您没有权限访问该页面");
        return next(`/user/login?redirect=${to.fullPath}`);
    }

    // 在路由守卫中添加
    if (!loginUserStore.initialized) {
        await loginUserStore.fetchLoginUser();
    }

    next();
})
  • main.ts中引入
import "./access";

4.3 知识点回顾:路由守卫

  1. 什么是路由守卫?

    • 路由守卫是 Vue Router 提供的一种机制,用于在页面跳转前或跳转后执行特定逻辑,比如用户权限校验、是否登录验证、数据预处理等。

    • 它的作用是“拦截导航行为”,让开发者可以在用户访问某个路由前,决定是否允许进入该页面。

  2. 常见类型的路由守卫:

  • 全局前置守卫
    在每次路由切换前触发,可用于全局权限判断。
router.beforeEach((to, from, next) => {
  const user = getLoginUser();
  if (to.meta.requiresAuth && !user) {
    next("/login");
  } else {
    next();
  }
});
  • 路由独享守卫
    写在路由配置里,仅对某个路由生效。
{
  path: '/admin',
  component: AdminPage,
  beforeEnter: (to, from, next) => {
    if (isAdmin()) next();
    else next('/403');
  }
}
  1. 组件内守卫/单页面守卫
    写在组件内部,适合做页面级别的校验或提示。

  2. 使用场景举例:

  • 用户未登录跳转登录页
  • 非管理员禁止访问后台页面
  • 路由跳转前弹出确认框(如表单未保存)

前端到这里基本就结束了,接下来回到项目导航站开发笔记继续完善后端~


欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1701220998@qq.com
导航页 GitHub