一、创建通用部分组件
前端初始化请参阅:项目导航站开发笔记
为了提高代码复用程度,我们先完善项目通用部分
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. 登录页:表单验证、状态绑定、接口调用、跳转逻辑
UserLoginPage.vue,基于 Ant Design 的表单组件 快速开发登录页面
<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 知识点回顾:路由守卫
什么是路由守卫?
路由守卫是 Vue Router 提供的一种机制,用于在页面跳转前或跳转后执行特定逻辑,比如用户权限校验、是否登录验证、数据预处理等。
它的作用是“拦截导航行为”,让开发者可以在用户访问某个路由前,决定是否允许进入该页面。
常见类型的路由守卫:
- 全局前置守卫
在每次路由切换前触发,可用于全局权限判断。
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');
}
}
组件内守卫/单页面守卫
写在组件内部,适合做页面级别的校验或提示。使用场景举例:
- 用户未登录跳转登录页
- 非管理员禁止访问后台页面
- 路由跳转前弹出确认框(如表单未保存)
前端到这里基本就结束了,接下来回到项目导航站开发笔记继续完善后端~
欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1701220998@qq.com