页面的布局
- 修改router/index.js文件
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import Layout from '../laylouts/index.vue';
// 工厂函数创建router实例
const router = createRouter({
history: createWebHashHistory(), // hash模式, h5模式createWebHistory
routes: [
{
path: '/',
component: Layout,
children: [
{ path: '/', component: () => import('views/Home.vue') }
]
}
]
})
export default router;
- 在src目录下新建layouts目录,并在layouts目录下创建index.vue文件,创建components目录,在components目录下创建Navbar.vue, AppMain.vue文件
// src/layouts/index.vue
<script setup lang="jsx">
import { defineProps, reactive, defineEmit, useContext } from 'vue';
import NavBar from './components/Navbar.vue';
import AppMain from './components/AppMain.vue';
export default {
setup() {
return() => {
return (
<div>
<nav></nav>
<main>
<NavBar></NavBar>
<AppMain></AppMain>
</main>
</div>
)
}
}
}
</script>
// src/layouts/components/Navbar.vue
<script setup lang="jsx">
export default {
setup() {
return () => {
return (
<div class="navbar">
<div class="right-menu">
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<img src="/src/assets/logo.png" class="user-avatar" />
<i class="el-icon-caret-bottom" />
</div>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> 首页 </el-dropdown-item>
</router-link>
<a target="_blank" href="https://github.com/tonyshu168/vite-vue3">
<el-dropdown-item>我的Github</el-dropdown-item>
</a>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
)
}
}
}
</script>
<style lang="scss" scope>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.breadcrumb-container {
float: left;
}
.right-menu {
float: right;
height: 100%;
line-height: 50px;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
padding: 0 8px;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.025);
}
}
}
.avatar-container {
margin-right: 30px;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.user-avatar {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon-caret-bottom {
cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
}
}
}
}
}
</style>
// src/layouts/components/AppMain.vue
<template>
<section class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</section>
</template>
<script>
export default {
name: "AppMain",
};
</script>
<style scoped>
.app-main {
/*50 = navbar */
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
}
</style>
动态导航侧边栏
- 开发侧边栏(src/layouts/components/Sidebar), 还需安装path-browserify。
目录结构如下:
-- src/layouts/components/Sidebar
-- index.vue, SidebarItem.vue, Item.vue, Link.vue
// Sidebar/index.uve
<template>
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
mode="vertical"
>
<sidebar-item
v-for="route in routes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</template>
<script setup>
import SidebarItem from './SidebarItem.vue';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { routes } from "router";
import variables from 'styles/variables.module.scss';
console.log('routes', routes );
const activeMenu = computed(() => {
const route = useRoute();
const { meta, path } = route;
if ( meta.activeMenu ) {
return meta.activeMenu;
}
return path;
})
</script>
// Sidebar/SidebarItem.vue
<template>
<div v-if="!item.hidden">
<template
v-if="
hasOneShowingChild(item.children, item) &&
(!onlyOneChild.children || onlyOneChild.noShowingChildren) &&
!item.alwaysShow
"
>
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
<el-menu-item :index="resolvePath(onlyOneChild.path)">
<item
:icon="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"
:title="onlyOneChild.meta.title"
/>
</el-menu-item>
</app-link>
</template>
<el-submenu
v-else
ref="subMenu"
:index="resolvePath(item.path)"
popper-append-to-body
>
<template #title>
<item
v-if="item.meta"
:icon="item.meta && item.meta.icon"
:title="item.meta.title"
/>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-submenu>
</div>
</template>
<script setup>
import path from "path-browserify";
import Item from "./Item.vue";
import AppLink from "./Link.vue";
import { isExternal } from "utils/validate.js";
import { defineProps, ref } from "vue";
const props = defineProps({
// route object
item: {
type: Object,
required: true,
},
isNest: {
type: Boolean,
default: false,
},
basePath: {
type: String,
default: "",
},
});
const onlyOneChild = ref(null);
const hasOneShowingChild = (children = [], parent) => {
const showingChildren = children.filter((item) => {
if (item.hidden) {
return false;
} else {
// Temp set(will be used if only has one showing child)
onlyOneChild.value = item;
return true;
}
});
// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return true;
}
// Show parent if there are no child router to display
if (showingChildren.length === 0) {
onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
return true;
}
return false;
};
const resolvePath = (routePath) => {
if (isExternal(routePath)) {
return routePath;
}
if (isExternal(props.basePath)) {
return props.basePath;
}
return path.resolve(props.basePath, routePath);
};
</script>
// Sidebar/Item.vue
<template>
<i v-if="icon" class="sub-el-icon" :class="icon"></i>
<span v-if="title">{{ title }}</span>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
icon: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
});
</script>
<style scoped>
.sub-el-icon {
color: currentColor;
width: 1em;
height: 1em;
}
</style>
// Sidebar/Link.vue
<template>
<component :is="type" v-bind="linkProps(to)">
<slot />
</component>
</template>
<script setup>
import { isExternal as isExt } from "utils/validate";
import { computed, defineProps } from "vue";
const props = defineProps({
to: {
type: String,
required: true,
},
});
const isExternal = computed(() => isExt(props.to));
// type是一个计算属性
const type = computed(() => {
if (isExternal.value) {
return "a";
}
return "router-link";
});
const linkProps = (to) => {
if (isExternal.value) {
return {
href: to,
target: "_blank",
rel: "noopener",
};
}
return { to };
};
</script>
面包屑的开发
router/index.js配置如下:
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import Layout from '../laylouts/index.vue';
export const routes = [
{
path: '/',
redirect: '/home',
component: Layout,
alwaysShow: true,
meta: { title: '导航', icon: 'el-icon-setting' },
children: [
{
path: 'home',
component: () => import('views/Home.vue'),
name: 'Home',
meta: { title: '首页', icon: 'el-icon-s-home' },
children: [
{
path: ':id',
component: () => import('views/detail.vue'),
name: 'Detail',
meta: {
title: '详情',
icon: 'el-icon-s-home',
activeMenu: '/home'
}
}
]
}
]
}
]
const router = createRouter({
history: createWebHashHistory(), // hash模式, h5模式createWebHistory
// history: createWebHistory(),
routes
})
export default router;
开发Breadcrumb.vue组件,需要安装依赖path-to-regexp进行路由的匹配与解释
// src/layouts/components/Breadcrumb.vue
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, idx) in levelList" :key="item.path">
<span
v-if="item.redirect === 'noRedirect' || idx == levelList.length - 1"
class="no-redirect"
>{{ item.meta.title }}</span>
<a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script setup>
import { compile } from 'path-to-regexp';
import { reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const levelList = ref(null);
const router = useRouter();
const route = useRoute();
function getBreadcrumb() {
let matched = route.matched.filter(item => item.meta && item.meta.title);
const first = matched[0];
if ( first.path !== '/' ) {
matched = [{ path: 'home', meta: { title: '首页'} }].concat(metched);
}
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false);
}
function pathCompile( path ) {
const toPath = compile(path);
return toPath(route.params);
}
function handleLink( item ) {
const { redirect, path } = item;
if ( redirect ) {
router.push(redirect);
return;
}
router.push(pathCompile(path));
}
getBreadcrumb();
watch(route, getBreadcrumb);
</script>
<style lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>
在src/layouts/components/Navbar.vue中添加Breadcrumb.vue组件
<script setup lang="jsx">
import Breadcrumb from './Breadcrumb.vue';
export default {
setup() {
return () => {
return (
<div class="navbar">
<Breadcrumb class="breadcrumb-container"></Breadcrumb>
<div class="right-menu">
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<img src="/src/assets/logo.png" class="user-avatar" />
<i class="el-icon-caret-bottom" />
</div>
<el-dropdown-menu class="user-dropdown">
<router-link to="/">
<el-dropdown-item> 首页 </el-dropdown-item>
</router-link>
<a target="_blank" href="https://github.com/tonyshu168/vite-vue3">
<el-dropdown-item>我的Github</el-dropdown-item>
</a>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
)
}
}
}
</script>
数据封装
- 安装axios:
npm i axios -D 或 yarn add axios -D
- 配置开发与生产环境的路径, 项目根目录下.env.development
VITE_BASE_API=/api
- 数据封装文件: src/utils/request.js
import axios from 'axios';
import { Message, Msgbox } from 'element3';
import store from 'store';
const service = axios.create({
baseURL: import.meta.env.VITE_BASE_API,
timeout: 5000
});
service.interceptors.request.use(
config => {
config.headers['X-Token'] = 'my token';
return config;
},
error => {
console.log(error);
return Promise.reject(error);
}
);
service.interceptors.response.use(
response => {
const res = response.data;
if ( res.code !== 2000 ) {
Message.error({
message: res.message || 'Error',
duration: 5 * 1000
});
// 5008: 非法令牌; 5012: 其他客户端已登入; 5014: 令牌过期;
if (res.code === 5008 || res.code === 5012 || res.code === 5014 ) {
Msgbox.confirm('您已登出,请重新登录', '确认', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload();
})
})
}
return Promise.reject(new Error(res.message || 'Error'));
}
else {
return res;
}
},
error => {
console.log('Err ' + error);
Message({
message: error.message,
type: 'error',
duration: 5 * 1000
});
return Promise.reject(error);
}
)
export default service;
- 测试数据文件: scr/components/HelloWorld.vue
import request from 'utils/request';
try {
const users = await request('/users');
console.log('users: ', users);
}
catch( error ) {
console.log('error', error);
}
表格数据的展现与管理
- 首先mock数据,项目根目录下mock/user.js
const mockList = [
{ id: 1, name: "tom", age: 18 },
{ id: 2, name: "jerry", age: 18 },
{ id: 3, name: "tony", age: 18 },
{ id: 4, name: "jack", age: 18 },
{ id: 5, name: "aric", age: 18 },
{ id: 6, name: "white", age: 18 },
{ id: 7, name: "peter", age: 18 },
{ id: 8, name: "jay", age: 18 },
];
module.exports = [
{
url: "/api/getUser",
type: "get",
response: () => {
return {
code: 2000,
data: { id: 1, name: "tom", age: 18 },
};
},
},
{
url: "/api/getUsers",
type: "get",
response: (config) => {
// 从查询参数中获取分页、过滤关键词等参数
const { page = 1, limit = 5 } = config.query;
// 分页
const data = mockList.filter(
(item, index) => index < limit * page && index >= limit * (page - 1)
);
return {
code: 2000,
data,
total: mockList.length,
};
},
},
{
url: "/api/addUser",
type: "post",
response: () => {
// 直接返回
return {
code: 2000,
};
},
},
{
url: "/api/updateUser",
type: "post",
response: () => {
return {
code: 2000,
};
},
},
{
url: "/api/deleteUser",
type: "get",
response: () => {
return {
code: 2000,
};
},
},
];
- 添加路由
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import Layout from '../laylouts/index.vue';
export const routes = [
{
path: '/',
redirect: '/home',
component: Layout,
alwaysShow: true,
meta: { title: '导航', icon: 'el-icon-setting' },
children: [
{
path: 'home',
component: () => import('views/Home.vue'),
name: 'Home',
meta: { title: '首页', icon: 'el-icon-s-home' },
children: [
{
path: ':id',
component: () => import('views/detail.vue'),
name: 'Detail',
meta: {
title: '详情',
icon: 'el-icon-s-home',
activeMenu: '/home'
}
}
]
}
]
},
{
path: "/users",
component: Layout,
meta: {
title: "用户管理",
icon: "el-icon-user-solid",
},
redirect: '/users/list',
children: [
{
path: "list",
component: () => import("views/users/list.vue"),
meta: {
title: "用户列表",
icon: "el-icon-document",
},
},
{
path: "create",
component: () => import("views/users/create.vue"),
hidden: true,
meta: {
title: "创建新用户",
activeMenu: "/users/list",
},
},
{
path: "edit/:id(\\d+)",
name: "userEdit",
component: () => import("views/users/edit.vue"),
hidden: true,
meta: {
title: "编辑用户信息",
activeMenu: "/users/list",
},
},
]
}
]
const router = createRouter({
history: createWebHashHistory(), // hash模式, h5模式createWebHistory
// history: createWebHistory(),
routes
})
export default router;
- 在views目录下创建users目录,在users目录分别创建model, components目录,并在components目录下创建detail.vue,modal目录下创建userModel.js, users目录分别创建list.uve,edit.vue,create.vue
// users/list.vue
<template>
<div class="app-container">
<div class="btn-container">
<!-- 新增按钮 -->
<router-link to="/users/create">
<el-button type="success" icon="el-icon-edit">创建用户</el-button>
</router-link>
</div>
<el-table
v-loading="loading"
:data="list"
border
fit
highlight-current-row
style="width: 100%"
>
<el-table-column align="center" label="ID" prop="id"></el-table-column>
<el-table-column align="center" label="账户名" prop="name"></el-table-column>
<el-table-column align="center" label="年龄" prop="age"></el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" align="center">
<template v-slot="scope">
<el-button
type="primary"
icon="el-icon-edit"
@click="handleUpdate(scope)"
>更新</el-button
>
<el-button
type="danger"
icon="el-icon-remove"
@click="handleDelete(scope)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<!-- 分布 -->
<pagination
v-show="total > 0"
:total="total"
v-model:page="listQuery.page"
v-model:limit="listQuery.limit"
@pagination="getList"
></pagination>
</div>
</template>
<script>
import { toRefs } from 'vue';
import { useRouter } from 'vue-router';
import { Message } from 'element3';
import Pagination from 'comps/Pagination.vue';
import { useList } from './model/userModel';
export default {
name: 'UserList',
components: {
Pagination
},
setup() {console.log('list.vue');
// 用户数据列表
const router = useRouter();
const { state, getList, delItem } = useList();
// 用户更新
function handleUpdate({ row }) {
router.push({
name: 'userEdit',
params: { id: row.id }
})
}
// 删除用户
function handleDelete({ row }) {
delItem(row.id).then(() => {
// todo: 删除这一行或重新获取数据
// 通知用户
Message.success('删除成功!');
})
}
return {
...toRefs(state),
getList,
handleUpdate,
handleDelete
}
}
}
</script>
<style lang="scss" scope>
.btn-container {
text-align: left;
padding: 0px 10px 20px 0px;
}
</style>
// users/edit.vue
<template>
<detail :is-edit="true"></detail>
</template>
<script>
import Detail from "./components/detail.vue";
export default {
components: {
Detail,
},
};
</script>
// users/create.vue
<template>
<detail :is-edit="false"></detail>
</template>
<script>
import Detail from "./components/detail.vue";
export default {
components: {
Detail,
},
};
</script>
// users/components/detail.vue
<template>
<div class="container">
<el-form ref="form" :model="model" :rules="rules">
<el-form-item prop="name" label="用户名">
<el-input v-model="model.name"></el-input>
</el-form-item>
<el-form-item prop="age" label="用户年龄">
<el-input v-model.number="model.age"></el-input>
</el-form-item>
<el-form-item>
<el-button @click="submitForm" type="primary">提交</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { Message } from "element3";
import { reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { useItem } from "../model/userModel";
export default {
props: {
isEdit: {
type: Boolean,
default: false,
},
},
setup(props) {
// 路由
const route = useRoute();
const { model, addUser, updateUser } = useItem(props.isEdit, route.params.id);
const rules = reactive({
// 校验规则
name: [{ required: true, message: "用户名为必填项" }],
});
// 表单实例
const form = ref(null);
// 提交表单
function submitForm() {
// 校验
form.value.validate((valid) => {
if (valid) {
// 提交
if (props.isEdit) {
updateUser().then(() => {
// 操作成功提示信息
Message.success({
title: "操作成功",
message: "更新用户数据成功",
duration: 2000,
});
});
} else {
addUser().then(() => {
// 操作成功提示信息
Message.success({
title: "操作成功",
message: "新增玩家数据成功",
duration: 2000,
});
});
}
}
});
}
return {
model,
rules,
form,
submitForm,
};
},
};
</script>
<style scoped>
.container {
padding: 10px;
}
</style>
<style>
.avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar-uploader .el-upload:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
line-height: 178px;
text-align: center;
}
.avatar {
width: 178px;
height: 178px;
display: block;
}
</style>
// model/userModel.js
import { reactive, onMounted, ref } from 'vue';
import request from 'utils/request';
export function useList() {
// 列表数据
const state = reactive({
loading: true, // 加载状态
list: [], // 列表数据
total: 0,
listQuery: {
page: 1,
limit: 5
}
});
// 获取列表数据
function getList() {
state.loading = true;
return request({
url: '/getUsers',
method: 'get',
params: state.listQuery
})
.then(({ data, total }) => {
state.list = data;
state.total = total;
})
.finally(() => {
state.loading = false;
})
}
// 删除项
function delItem(id) {
state.loading = true;
return request({
url: '/deleteUser',
method: 'get',
params: { id }
})
.finally(() => {
state.loading = false;
})
}
// 首次获取数据
getList();
return { state, getList, delItem };
}
const defaultData = {
name: '',
age: undefined
};
export function useItem(isEdit, id) {
const model = ref(Object.assign({}, defaultData));
// 初始化时,根据isEdit判定是否需要获取用户详情
onMounted(() => {
if ( isEdit && id ) {
// 获取用户详情
request({
url: '/getUser',
method: 'get',
params: { id }
})
.then(({ data }) => {
model.value = data;
});
}
});
const updateUser = () => {
return request({
url: '/updateUser',
method: 'post',
data: model.value
})
};
const addUser = () => {
return request({
url: '/addUser',
method: 'post',
data: model.value
})
};
return { model, updateUser, addUser };
}
- 在src/components目录下创建分页组件Pagination.vue
// src/components/Pagination.vue
<template>
<div :class="{ hidden: hidden }" class="pagination-container">
<el-pagination
:background="background"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
v-bind="$attrs"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script>
export default {
name: "Pagination",
props: {
total: {
required: true,
type: Number,
},
page: {
type: Number,
default: 1,
},
limit: {
type: Number,
default: 20,
},
pageSizes: {
type: Array,
default() {
return [10, 20, 30, 50];
},
},
layout: {
type: String,
default: "total, sizes, prev, pager, next, jumper",
},
background: {
type: Boolean,
default: true,
},
hidden: {
type: Boolean,
default: false,
},
},
emits: ["update:page", "update:limit", "pagination"],
computed: {
currentPage: {
get() {
return this.page;
},
set(val) {
this.$emit("update:page", val);
},
},
pageSize: {
get() {
return this.limit;
},
set(val) {
this.$emit("update:limit", val);
},
},
},
methods: {
handleSizeChange(val) {
this.$emit("pagination", { page: this.currentPage, limit: val });
},
handleCurrentChange(val) {
this.$emit("pagination", { page: val, limit: this.pageSize });
},
},
};
</script>
<style scoped>
.pagination-container {
background: #fff;
padding: 32px 16px;
}
.pagination-container.hidden {
display: none;
}
</style>
项目打包, 将打包的目录上传到服务器对应目录即可。
yarn run build