vue快速开发项目模板搭建
Vue3项目构建指南 本文介绍了如何快速搭建一个基于Vue3的前端项目框架,主要内容包括:项目初始化、目录结构设计、核心配置项以及常用工具集成。 项目使用Vite构建工具创建,核心功能模块包括: 采用Pinia进行状态管理 使用Vue Router实现路由控制 集成Tailwind CSS处理样式 配置Axios进行网络请求 添加PWA支持和服务 实现页面加载进度条效果 文章详细讲解了各模块的安装
代码仓库
gitee之前写的
gitee本文案例, 请下载分支 5_other
创建项目
# 我个人比较喜欢用pnpm
npm create vite
pnpm create vite custom-vue-starter
# 敲命令后会弹出选项, 选vue 然后选JavaScript
cd custom-vue-starter
pnpm i
目录结构
一个完整的前端项目需要:
- 状态管理
在全局维护共有的状态(数据), 让页面组件之间共享数据, 我们使用pinia - 路由
路由让页面之间可以进行跳转, 我们使用vue-router - 样式
样式让页面更美观, 我们使用tailwindcss - 网络请求
前端需要通过网络请求的方式和后端进行数据交互, 从而实现功能, 我们使用axios
├── dev-dist/ # 开发环境构建输出目录
├── node_modules/ # Node.js依赖包目录
├── public/ # 静态资源目录,不会被构建工具处理
├── src/
├── admin/ # 存放后台管理页面
├── api/ # 接口请求逻辑
├── assets/ # 静态资源(图片、字体等)
├── components/ # 公共组件
├── includes/ # 包含文件,存放外部库
├── lib/ # 存放项目内的一些公共资源
├── locales/ # 国际化语言包
├── mocks/ # 模拟数据
├── pages/ # 存放普通的页面组件
├── router/ # 路由配置
├── store/ # 状态管理(Pinia)
├── styles/ # 全局样式或CSS文件
├── utils/ # 工具函数
├── App.vue # 根组件
├── main.js # 项目入口文件
├── middleware.js # 中间件逻辑(如路由守卫)
└── settings.js # 项目设置或配置文件
├── .gitignore # Git忽略文件配置
├── index.html # 项目入口HTML文件
├── package.json # 项目配置及依赖声明
├── postcss.config.js # PostCSS配置文件
├── README.md # 项目说明文档
├── tailwind.config.js # Tailwind CSS配置文件
├── vite.config.js # Vite构建工具配置文件
配置
路径别名
配置一下路径别名, 用@/表示src/目录
# node的类型声明, 防止使用node的依赖后报错
pnpm i @types/node --save-dev
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
// 路径别名
resolve: {
alias: {
'@':
join(__dirname, 'src'),
}
}
})
项目的设置
export default {
routeMode: 'history', // 路由模式
BaseURL: 'http://localhost:4000', // 后端请求地址
timeout: 5000, // 请求超时时间
}
PWA配置
# v2
pnpm i vite-plugin-pwa --save-dev
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'
import {VitePWA} from "vite-plugin-pwa";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
// 生成清单文件
enabled: true
},
manifest: {
name: "vue-quick-start",
theme_color: '#ff5e3a',
icons: [
{
src: 'assets/logo.png',
size: '192x192',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,jpg}']
}
})
],
resolve: {
alias: {
'@':
join(__dirname, 'src'),
}
}
})
加载进度条
pnpm i nprogress
src/main.js
import 'nprogress/nprogress.css'
import progressBar from "./includes/progress-bar";
// 进度条
progressBar(router)
app.use(router)
includes/progress-bar
import NProgress from "nprogress";
export default (router) => {
router.beforeEach((to, from, next) => {
NProgress.start();
next();
});
router.afterEach(NProgress.done);
};
tailwind
使用tailwind3(如果你要用4的也可以, 不过两个安装有点区别)
pnpm install -D tailwindcss@3 postcss autoprefixer
pnpm dlx tailwindcss@3 init -p
tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
styles/base.css
@tailwind base;
@layer base {
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
h3 {
@apply text-lg;
}
h4 {
@apply text-base;
}
h5 {
@apply text-sm;
}
h6 {
@apply text-xs;
}
}
@tailwind components;
@tailwind utilities;
body {
@apply h-full w-full p-0 m-0;
}
/* 覆盖默认的最大宽度限制 */
@media (min-width: 1536px) {
.container {
max-width: 100%;
}
}
/* 页面主内容 */
.container {
width: 100%;
height: 100%;
background-color: #f5f7fb;
padding: 20px;
.container-wrapper {
width: 100%;
height: 100%;
padding: 15px 30px 0;
background-color: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
/* 设置打印控件样式 */
.plugin-download {
width: 500px !important;
a:hover {
text-decoration: underline;
}
}
记得在src/main.js引入
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
createApp(App)
.mount('#app')
路由 router
使用vue-router进行路由跳转, 并且我们会实现文件路由, 自动扫描目录下的page.vue文件, 然后注册为路由,
有两个页面目录, 一个是admin, 一个是pages, admin目录是后台管理页面, pages目录是普通的页面组件
# v4
pnpm i vue-router@4
文件路由
文件路由就是根据目录结构, 自动扫描并注册路由, 不需要我们一个一个手动声明注册
实现的关键是
import.meta.glob("xxx")
这是由vite提供的方法, 可以扫描获取文件, webpack也有类似的方法
require.context()
我们使用的是vite, 使用import.meta.glob就行, 现在来实现文件扫描功能
pages/index.js
// src/pages/index.js
// 扫描pages目录下所有page.vue
const pages = import.meta.glob('./**/page.vue')
// 先扫描出扁平结构
const routes = Object.keys(pages).map(key => {
const path = key
.replace(/^\.\//, '') // 去掉 ./
.replace(/\/?page\.vue$/, ''); // 去掉 /page.vue
return {
path: `/${path}`,
name: path,
component: () => pages[key](),
meta: {
title: path.split('/').pop() || '首页'
},
hidden: false,
whiteList: true
};
});
// 插入父级节点
const allPaths = routes.map(route => route.path);
const parentRoutes = new Set();
allPaths.forEach(path => {
// /a/b/c -> /a/b
let currentPath = path.substring(0, path.lastIndexOf('/'));
console.log('currentPath', currentPath)
while (currentPath) {
// 让 /a/b 的父路径 /a 也出现在路由中,从而支持菜单层级结构
if (!parentRoutes.has(currentPath)) {
routes.push({
// /a
path: currentPath,
// name: -a
name: currentPath.replace(/\//g, '-'),
meta: {title: currentPath.split('/').pop()}
});
parentRoutes.add(currentPath);
}
// /a/b -> /a
currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
}
});
console.log('scan routes', routes)
export default routes
app.vue渲染子路由
<template>
<router-view/>
</template>
admin/index.js
// src/admin/index.js
const pages = import.meta.glob('./**/page.vue')
// 先扫描出扁平结构
const routes = Object.keys(pages).map(key => {
const path = key
.replace(/^\.\//, '') // 去掉 ./
.replace(/\/?page\.vue$/, ''); // 去掉 /page.vue
// 提取父路径
// const parentPath = path.substring(0, path.lastIndexOf('/'));
return {
path: path, // 使用相对路径, 才能放入children里被嵌套为子路由
// path: `/${path}`,
name: path,
component: () => pages[key](),
meta: {
title: path.split('/').pop() || '首页'
},
hidden: false,
whiteList: true
};
});
// 插入父级节点
const allPaths = routes.map(route => route.path);
const parentRoutes = new Set();
allPaths.forEach(path => {
// /a/b/c -> /a/b
let currentPath = path.substring(0, path.lastIndexOf('/'));
console.log('currentPath', currentPath)
while (currentPath) {
// 让 /a/b 的父路径 /a 也出现在路由中,从而支持菜单层级结构
if (!parentRoutes.has(currentPath)) {
routes.push({
// /a
path: currentPath,
// name: -a
name: currentPath.replace(/\//g, '-'),
meta: {title: currentPath.split('/').pop()}
});
parentRoutes.add(currentPath);
}
// /a/b -> /a
currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
}
});
console.log('scan admin routes', routes)
export default routes
admin/base.vue, 先简单渲染子路由, 后面组件的部分再完善管理系统骨架
<template>
<router-view/>
</template>
这两个代码是差不多的, 原理就是扫描目录, 然后还要处理一下嵌套的结构, 扫描处理完放入一个route数组, 这个route数组就是路由的定义,
可以统一注册到全局路由
现在看看路由的代码
// src/router/index.js
import {createRouter, createWebHashHistory, createWebHistory} from 'vue-router'
import routes from "@/pages/index.js";
import adminRoutes from '@/admin/index.js'
import settings from "../settings.js";
const router = createRouter({
// import.meta.env.BASE_URL 指向 vite.config.js 中的 base 配置
history: settings.routeMode === 'history'
? createWebHistory(import.meta.env.BASE_URL)
: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/about',
name: 'about',
component: () => import('@/pages/about/page.vue')
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/login/page.vue')
},
{
path: '/404',
name: '404',
component: () => import('@/pages/404.vue'),
beforeEnter: (to, from, next) => {
console.log('Router Guard')
console.log(to, from)
next()
}
},
// 扫描pages下vue
...routes,
// 扫描admin下vue
{
name: 'admin',
path: '/admin',
component: () => import('@/admin/base.vue'),
children: [
...adminRoutes
]
},
{
path: '/:catchAll(.*)*',
// 上面的都匹配不到,就重定向到404
redirect: {name: '404'}
}
]
})
// 全局路由守卫
router.beforeEach((to, from, next) => {
console.log('Global Guard')
console.log(to, from)
// 如果路由元信息中有 title,则设置 document.title
if (to.meta && to.meta.title) {
document.title = to.meta.title
}
next()
})
export default router
记得在src/main.js中引入并使用
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
createApp(App)
.use(router)
.mount('#app')
此时你可以在pages和admin下创建测试文件, 比如pages/a/b/c/page.vue, 然后启动项目访问
组件 components
我们在components目录下存放组件, 通过该目录下的index.js自动扫描并注册到vue实例上, 然后就可以全局使用了
// 转换逻辑,将短横线转为驼峰再首字母大写
function toPascalCase(str) {
return str
.split('-')
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join('')
}
export default {
install: (app) => {
// 同步扫描当前目录下的所有 .vue 文件
const components = import.meta.glob('./**/*.vue', {eager: true})
for (const path in components) {
const componentName = path
.replace(/^\.\//, '')
.replace(/\.\w+$/, '')
.split('/')
.map(toPascalCase)
.join('')
// 获取组件定义(同步)
const component = components[path].default
// 注册为全局组件
app.component(componentName, component)
}
}
}
这里的install是vue插件逻辑, vue实例use的时候会自动调用install方法
在src/main.js中使用
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
import components from "@/components/index.js";
createApp(App)
.use(router)
.use(components)
.mount('#app')
测试:
components/hello-world.vue
<template>
hello
</template>
components/mal/red.vue
<template>
malred
</template>
pages/page.vue
<template>
<hello-world/>
<mal-red/>
</template>
element plus
之前admin/base.vue只简单渲染子路由的内容, 现在我们需要加入一些骨架, 比如左侧菜单, 顶部导航, 底部版权;
而这些骨架都是组件, 这些组件我们用element plus组件库来简化开发
# v2
pnpm i element-plus @element-plus/icons-vue
在src/main.js使用
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
import components from "@/components/index.js";
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
app.use(router)
// 全局安装element+ icons
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(components)
app.mount('#app')
layout组件
<!--
* @Author Malred
* @Date 2025-06-01 22:28:33
* @Description
* @Path src/components/layout/aside/menu-item.vue
-->
<template>
<div :key="item.path">
<el-menu-item
v-if="!item.children || !item.children.length"
:index="item.path"
>
{{ item.meta.title || item.name }}
</el-menu-item>
<el-sub-menu v-else :index="item.path" :key="item.path">
<template #title>{{ item.meta.title || item.name }}</template>
<MenuItem v-for="child in item.children" :key="child.path" :item="child"/>
</el-sub-menu>
</div>
</template>
<script>
export default {
name: 'MenuItem',
props: ['item']
};
</script>
<style scoped>
</style>
<!--
* @Author Malred
* @Date 2025-06-01 06:30:07
* @Description 侧边导航
* @Path src/components/layout/aside.vue
-->
<template>
<div class="aside-container">
<!-- Logo -->
<div class="logo-section">
<img src="../../assets/logo.png" alt="Logo" class="logo-img"/>
<span class="logo-title">Malred</span>
</div>
<!-- 菜单 -->
<el-menu
:default-active="$route.path"
class="el-menu-vertical-demo"
background-color="#409EFF"
text-color="white"
active-text-color="#ffd04b"
:router="true"
>
<menu-item v-for="item in menuTree" :key="item.path" :item="item"/>
</el-menu>
</div>
</template>
<script>
import routes from '@/admin/index';
import {buildMenuTree} from '@/utils/menu';
import MenuItem from "./aside/menu-item.vue";
export default {
components: {
MenuItem
},
data() {
return {
// menuTree: buildMenuTree(routes)
menuTree: buildMenuTree(routes.map(item => ({
...item,
path: '/admin/' + item.path
})))
};
},
mounted() {
console.log(this.menuTree)
}
};
</script>
<style scoped>
.aside-container {
height: 100%;
}
.logo-section {
background-color: #4fa3fa;
display: flex;
align-items: center;
padding: 14px;
color: white;
}
.logo-img {
width: 32px;
height: 32px;
margin-right: 10px;
}
.logo-title {
font-size: 18px;
font-weight: bold;
}
</style>
<!--
* @Author Malred
* @Date 2025-06-01 06:52:12
* @Description 顶部导航
* @Path src/components/layout/header.vue
-->
<template>
<div class="header-container">
<el-menu
mode="horizontal"
background-color="#4fa3fa"
text-color="white"
active-text-color="#ffd04b"
:router="true"
>
<el-menu-item index="/">首页</el-menu-item>
<el-sub-menu index="/about" popper-append-to-body>
<template #title>关于</template>
<el-menu-item index="/about/team">团队介绍</el-menu-item>
<el-menu-item index="/about/contact">联系我们</el-menu-item>
</el-sub-menu>
<el-menu-item index="/services">服务</el-menu-item>
</el-menu>
<!-- 右侧工具栏,使用 margin-left: auto 推到最右 -->
<div class="right-tools">
<translation-btn style="margin-right: 16px;"/>
<el-button circle style="margin-right: 8px;">
<el-icon>
<bell/>
</el-icon>
</el-button>
<el-button circle>
<el-icon>
<user/>
</el-icon>
</el-button>
</div>
</div>
</template>
<script setup>
</script>
<style scoped>
.header-container {
width: 100%;
display: flex;
align-items: center;
}
.right-tools {
display: flex;
align-items: center;
margin-left: auto; /* 关键:将整个 div 推到最右侧 */
}
</style>
admin/base.vue
<!--
* @Author Malred · Wang
* @Date 2025-06-17 11:26:20
* @Description
* @Path src/admin/base.vue
-->
<template>
<el-container style="height: 100vh; width: 100vw;">
<!-- 左侧边栏 -->
<el-aside width="200px" style="background-color: #409EFF; height: 100vh;">
<layout-aside/>
</el-aside>
<!-- 主体内容 -->
<el-container style="flex: 1; min-width: 0;">
<!-- 头部 -->
<el-header style="background-color: #4fa3fa; height: 60px; display: flex; align-items: center;">
<layout-header/>
</el-header>
<!-- 内容区域 -->
<el-main style="margin: 0; padding: 0; height: calc(100vh - 60px);">
<router-view/>
</el-main>
<!-- 底部 -->
<el-footer style="display: flex; justify-content: center; align-items: center;">
版权所有 @Malred
</el-footer>
</el-container>
</el-container>
</template>
<script setup>
</script>
<style scoped>
.el-main {
--el-main-padding: 0px;
}
</style>
utils/menu.js
/*
* @Author Malred · Wang
* @Date 2025-06-17 12:52:12
* @Description
* @Path src/utils/menu.js
*/
export function buildMenuTree(routes) {
console.log('build menu routes', routes)
const tree = [];
const map = {};
// 先全部映射到 map
routes.forEach(route => {
map[route.path] = {...route, children: []};
});
// 构建树结构
routes.forEach(route => {
const path = route.path;
const parentPath = path.substring(0, path.lastIndexOf('/'));
if (parentPath && map[parentPath]) {
map[parentPath].children.push(map[path]);
} else {
tree.push(map[path]);
}
});
return tree;
}
base组件
还有一些管理页面的组件, 我们也写一下
<!-- base-operation-item.vue -->
<template>
<el-form-item :label="label">
<el-input
:modelValue="value"
@update:modelValue="$emit('update:value', $event)"
/>
</el-form-item>
</template>
<script setup>
import {defineProps, defineEmits} from 'vue';
defineProps({
label: String,
value: [String, Number]
});
const emit = defineEmits(['update:value']);
</script>
<!--
* @Author Malred
* @Date 2025-06-02 05:13:16
* @Description 弹窗组件
* @Path src/components/base/operation.vue
-->
<!-- Operation.vue -->
<template>
<el-dialog v-model="visible" :title="title" width="50%">
<el-form :model="formData" label-width="120px">
<slot></slot>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup>
import {ref} from 'vue';
// 定义 emits
const emit = defineEmits(['submit']);
// 控制弹窗显示
const visible = ref(false);
// 显示方法
const show = () => {
visible.value = true;
};
// 提交方法
const submit = () => {
emit('submit'); // 触发 submit 事件
visible.value = false;
};
// 暴露方法给父组件调用
defineExpose({show});
import {defineProps} from 'vue';
defineProps({
title: String,
formData: {
type: Object,
default: () => ({})
}
});
</script>
<!--
* @Author Malred
* @Date 2025-06-02 06:18:53
* @Description 日期选择搜索组件
* @Path src/components/base/table/search/date.vue
-->
<!-- BaseSearchDate.vue -->
<template>
<div class="search-item">
<label v-if="label">{{ label }}</label>
<el-date-picker
v-model="value"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
/>
</div>
</template>
<script setup>
import {defineProps, defineModel} from 'vue';
defineProps({
label: String
});
const value = defineModel();
</script>
<style scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
/*flex-wrap: wrap;*/
gap: 8px;
}
</style>
<!--
* @Author Malred
* @Date 2025-06-02 05:12:31
* @Description 普通的输入搜索框
* @Path src/components/base/table/search/item.vue
-->
<!-- SearchItem.vue -->
<template>
<div class="search-item">
<label v-if="label">{{ label }}</label>
<el-input style="width: 120px;" v-model="value" placeholder="请输入"/>
</div>
</template>
<script setup>
import {defineProps, defineModel} from 'vue';
const props = defineProps({
label: String
});
const value = defineModel();
</script>
<style scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
/*flex-wrap: wrap;*/
gap: 8px;
}
</style>
<!--
* @Author Malred
* @Date 2025-06-02 06:18:42
* @Description 下拉选择搜索框
* @Path src/components/base/table/search/select.vue
-->
<!-- BaseSearchSelect.vue -->
<template>
<div class="search-item">
<label v-if="label">{{ label }}</label>
<el-select v-model="value" placeholder="请选择">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
</template>
<script setup>
import {defineProps, defineModel} from 'vue';
defineProps({
label: String,
options: {
type: Array,
required: true,
default: () => []
}
});
const value = defineModel();
</script>
<style scoped>
.search-item {
display: flex;
flex-direction: row;
align-items: center;
/*flex-wrap: wrap;*/
width: 120px;
label {
width: 56px;
}
}
</style>
<!--
* @Author Malred
* @Date 2025-06-02 05:10:43
* @Description 表头组件
* @Path src/components/base/table/header.vue
-->
<!-- base-table-header.vue -->
<template>
<div class="table-header">
<!-- 插槽可以自由插入任意结构,比如 el-form -->
<div class="search-container">
<slot name="search"></slot>
</div>
<div class="actions">
<slot name="action">
<el-button type="primary" @click="$emit('add')">新增</el-button>
<el-button
type="danger"
@click="onDeleteSelected"
:disabled="deleteDisabled"
>
删除选中
</el-button>
</slot>
</div>
</div>
</template>
<script setup>
defineProps({
deleteDisabled: Boolean,
onDeleteSelected: {
type: Function,
default: null
}
});
defineEmits(['add']);
</script>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center; /* 垂直居中 */
margin-bottom: 12px;
margin-top: 8px;
flex-wrap: wrap;
gap: 12px;
}
.search-container {
display: flex;
align-items: center;
flex-grow: 1;
min-width: 200px;
gap: 8px;
}
.actions {
display: flex;
gap: 8px;
}
</style>
<!--
* @Author Malred
* @Date 2025-06-02 05:11:31
* @Description 表格body组件
* @Path src/components/base/table/body.vue
-->
<template>
<el-table :data="data" border style="width: 100%" @selection-change="handleSelectionChange">
<!-- 多选列 -->
<el-table-column type="selection" width="55"></el-table-column>
<!-- 动态列 -->
<el-table-column
v-for="(col, index) in columns"
:key="index"
:prop="col.prop"
:label="col.label"
:width="col.width"
>
<template #default="scope">
<slot :name="'col-' + col.prop" :row="scope.row">
{{ scope.row[col.prop] }}
</slot>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="150">
<template #default="scope">
<slot name="operation" :row="scope.row">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</slot>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
import {defineProps, defineEmits, ref} from 'vue';
const props = defineProps({
data: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
onDelete: { // 新增 prop,接收删除方法
type: Function,
default: null
}
});
const emit = defineEmits(['edit', 'delete', 'selection-change']);
const selectedRows = ref([]);
const handleSelectionChange = (rows) => {
selectedRows.value = rows;
emit('selection-change', rows);
};
const handleEdit = (row) => {
emit('edit', row);
};
const handleDelete = (row) => {
if (props.onDelete) {
props.onDelete([row]);
} else {
emit('delete', [row]);
}
};
</script>
测试: admin/test/page.vue
<!--
* @Author Malred · Wang
* @Date 2025-06-17 13:27:45
* @Description 测试表格组件
* @Path src/admin/test/page.vue
-->
<template>
<div class="container">
<div class="container-wrapper space-y-4">
<base-table-header
:delete-disabled="selectedRows.length === 0"
@add="openAddDialog"
:onDeleteSelected="handleDeleteSelected"
>
<template #search>
<!-- 文本输入 -->
<base-table-search-item label="用户名" v-model="searchForm.username"/>
<!-- 邮箱输入 -->
<base-table-search-item label="邮箱" v-model="searchForm.email"/>
<!-- ID 输入 -->
<base-table-search-item label="ID" v-model="searchForm.id"/>
<!-- 状态选择 -->
<base-table-search-select
label="状态"
v-model="searchForm.status"
:options="[
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' }
]"
/>
<!-- 创建时间 -->
<base-table-search-date label="创建时间" v-model="searchForm.createTime"/>
<!-- 清空按钮 -->
<el-button type="info" size="small" @click="resetSearchForm">重置</el-button>
</template>
</base-table-header>
<base-table-body
:data="tableData"
:columns="columns"
@selection-change="handleSelectionChange"
@edit="openEditDialog"
@delete="handleDelete"
:onDelete="handleDelete"
>
<!-- 自定义操作列 -->
<template #operation="props">
<el-button link type="primary" size="small" @click="openEditDialog(props.row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete([props.row])">删除</el-button>
</template>
</base-table-body>
<!-- 弹窗部分 -->
<base-operation ref="operationDialog" title="用户信息" @submit="submitForm">
<base-operation-item
label="用户名"
:value="formData.username"
@update:value="val => formData.username = val"
/>
<base-operation-item
label="邮箱"
:value="formData.email"
@update:value="val => formData.email = val"
/>
</base-operation>
</div>
</div>
</template>
<script setup>
import {ref, watch} from 'vue';
// 数据源
const originalData = [
{id: 1, username: 'admin', email: 'admin@example.com', status: 'active', createTime: '2025-06-01'},
{id: 2, username: 'user1', email: 'user1@example.com', status: 'inactive', createTime: '2025-06-02'},
{id: 3, username: 'test', email: 'test@example.com', status: 'active', createTime: '2025-06-03'}
];
const tableData = ref([...originalData]);
// 表格列配置
const columns = [
{prop: 'id', label: 'ID'},
{prop: 'username', label: '用户名'},
{prop: 'email', label: '邮箱'},
{prop: 'status', label: '状态'},
{prop: 'createTime', label: '创建时间'},
];
// 搜索表单
const searchForm = ref({
username: '',
email: '',
id: '',
status: '',
createTime: ''
});
// 重置搜索表单
const resetSearchForm = () => {
searchForm.value = {
username: '',
email: '',
id: '',
status: '',
createTime: ''
};
};
// 新增/编辑表单数据
const formData = ref({
username: '',
email: '',
});
const operationDialog = ref(null);
// 打开新增弹窗
const openAddDialog = () => {
formData.value = {username: '', email: ''};
operationDialog.value.show();
};
// 打开编辑弹窗
const openEditDialog = (row) => {
formData.value = {...row};
operationDialog.value.show();
};
// 提交新增/编辑
const submitForm = () => {
if (!formData.value.id) {
// 新增
const newRow = {
id: Date.now(),
username: formData.value.username,
email: formData.value.email
};
tableData.value.unshift(newRow);
} else {
// 编辑
const index = tableData.value.findIndex(item => item.id === formData.value.id);
if (index > -1) {
tableData.value.splice(index, 1, {...formData.value});
}
}
};
// 多选行
const selectedRows = ref([]);
const handleSelectionChange = (rows) => {
selectedRows.value = rows;
};
// 删除
const handleDelete = (rows) => {
tableData.value = tableData.value.filter(row =>
!rows.some(r => r.id === row.id)
);
};
// 批量删除
const handleDeleteSelected = () => {
if (selectedRows.value.length === 0) return;
handleDelete(selectedRows.value);
};
function applySearch(data, search) {
const {username, email, id, status, createTime} = search;
return data.filter(item => {
return (
(!username || item.username.toLowerCase().includes(username.toLowerCase())) &&
(!email || item.email.toLowerCase().includes(email.toLowerCase())) &&
(!id || item.id.toString().includes(id.toString())) &&
(!status || item.status === status) &&
(!createTime || item.createTime === createTime)
);
});
}
// 监听搜索变化
watch(
() => searchForm.value,
(newVal) => {
tableData.value = applySearch(originalData, newVal);
},
{deep: true}
);
</script>
<style scoped>
.search-form {
display: flex;
align-items: center; /* 关键:垂直居中 */
gap: 12px; /* 可选:增加项之间间距 */
}
</style>
i18n切换按钮
i18n是国际化的常用库
# v9
pnpm i vue-i18n
src/includes/i18n.js
import {createI18n} from "vue-i18n";
export const messages = {};
// 扫描locales目录, 注册i18n国际化语言包
const modules = import.meta.glob('../locales/*.json'); // 只读取当前目录下的 json 文件,不递归子目录
for (const path in modules) {
const key = path.match(/\.\.\/locales\/([^.]+)\.json$/)?.[1];
if (key) {
const module = await modules[path]();
messages[key] = module.default; // 注意这里加了 .default
}
console.log(key) // en
console.log(path)
}
console.log('messages', messages)
export default createI18n({
legacy: false, // Vue 3 Composition API 模式
locale: 'cn', // 默认语言
fallbackLocale: 'cn',
messages,
});
main.js
import i18n from "@/includes/i18n.js";
app.use(i18n)
src/locales/cn.json
{
"error": {
"nofound": "页面跑丢了~!"
},
"greet": "你好",
"info": {
"translate": {
"btn": {
"cn": "中文",
"en": "英文"
}
}
}
}
src/locales/en.json
{
"error": {
"nofound": "404 NOT FOUND!"
},
"greet": "hello",
"info": {
"translate": {
"btn": {
"cn": "chinese",
"en": "english"
}
}
}
}
转换语言组件 src/components/translation-btn.vue
<template>
<el-dropdown trigger="click" @command="handleSelect">
<el-button circle style="background-color: transparent;">
<img src="../assets/icons/components/translation.svg" alt="Language" style="width: 20px; height: 20px;"/>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="option in options" :key="option.key" :command="option.key">
{{ option.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
const {t, locale} = useI18n()
// 动态生成语言选项
const options = computed(() => [
{
label: t('info.translate.btn.en'),
key: 'en'
},
{
label: t('info.translate.btn.cn'),
key: 'cn'
}
])
// 切换语言
const handleSelect = (key) => {
locale.value = String(key)
}
</script>
<style scoped>
/* 可选:调整按钮样式 */
.el-button {
border: none;
padding: 0;
width: 36px;
height: 36px;
}
</style>
公共方法 utils
在utils文件夹下存放功能函数, 我们通过扫描该目录下的js文件, 然后挂载到vue实例上来全局使用
// index.js
const modules = import.meta.glob('./*.js'); // 注意:只读取当前目录的js文件,不递归
const utils = {};
for (const path in modules) {
const moduleName = path.replace('./', '').replace('.js', '');
const module = await modules[path](); // 获取对应路径的模块
utils[moduleName] = module.default; // 提取默认导出
}
export default {
install: (app) => {
app.config.globalProperties.$utils = utils;
},
};
获取vue3实例的方法
// vue-instance.js
import {getCurrentInstance} from "vue"
export const instance = () => getCurrentInstance()
.appContext.config.globalProperties
export const api = () => instance().$api
export const utils = () => instance().$utils
// main.js
import utils from '@/utils/index.js'
app.use(utils)
测试
// utils/test.js
export default {
test: () => console.log('test')
}
<!--app.vue-->
<script setup>
import {utils} from "./utils/vue-instance.js";
utils().test.test()
</script>
状态管理 store
之前学习vue的时候会发现原生的组件传值会遇到很麻烦的情况, 比如兄弟/跨级传值, 而我们可以使用全局状态管理的方案了解决
这里我们使用pinia
# v3
pnpm i pinia
// index.js
import {createPinia} from 'pinia'
import {useUserStore} from './user-store.js'
// 注册所有 store(可选)
// 这里只是为了确保所有 store 都被加载
export {
useUserStore
}
// 导出 pinia 实例供 main.js 使用
export default createPinia()
// user-store.js
import {defineStore, acceptHMRUpdate} from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: '张三',
age: 25
}),
// getters: {},
actions: {
setName(newName) {
this.name = newName
}
}
})
// 支持pinia热更新, 不需要刷新浏览器
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
}
// main.js
import store from "@/store/index.js";
app.use(store)
测试
<template>
<div>
<div @click="userStore.setName('malred')">{{ userStore.name }}</div>
</div>
</template>
<script setup>
import {useUserStore} from "../store/index.js";
const userStore = useUserStore()
</script>
网络请求 api
使用axios进行网络请求, 一般情况下, 前后端是分人分组开发的, 前端请求接口地址是后端提供的
如果你此时后端还没有接口, 就需要自己用假数据或者用json-server来模拟测试
# v1
pnpm i axios
封装
// request.js
import axios from 'axios'
import settings from "../settings.js";
const request = axios.create({
baseURL: settings.BaseURL, // 设置基础URL
timeout: settings.timeout, // 请求超时时间
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么,例如添加 token
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => {
// 对请求错误做些什么
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
// 对响应数据做处理
return response.data
},
(error) => {
// 对响应错误做处理
return Promise.reject(error)
}
)
export default request
扫描并注册到vue实例
// index.js
const modules = import.meta.glob('./**/index.js') // 只扫描 index.js 文件
const requests = {}
for (const path in modules) {
if (path === './index.js') continue // 跳过自身
const modulePath = path
.replace('./', '')
.replace('/index.js', '') // 去除前缀和后缀
const segments = modulePath.split('/') // 分割路径层级
// 安全获取模块名:如果是单层目录(如 admin/index),则直接取第一个段
const moduleName = segments[segments.length - 1]
console.log('api', moduleName)
console.log('api', segments)
let currentLevel = requests
// 构建嵌套结构
// for (let i = 0; i < segments.length - 1; i++) {
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
currentLevel[segment] = currentLevel[segment] || {}
currentLevel = currentLevel[segment]
}
// 加载模块并挂载到对应位置
// 要加await, 否则为这些函数为空
await modules[path]().then(module => {
console.log('currentLevel', currentLevel)
console.log('moduleName', moduleName)
// currentLevel[moduleName] = module.default
// 然后直接赋值给 currentLevel(不再使用 moduleName)
Object.assign(currentLevel, module.default)
// currentLevel = module.default
})
}
export default {
install: (app) => {
// app.config.globalProperties.$requests = requests
app.config.globalProperties.$api = requests
},
}
添加到main.js
// main.js
import api from '@/api/index.js'
app.use(api)
测试
// api/admin/index.js
import request from "../request.js";
export default {
// 查询全部
queryAdmin(body) {
return request({url: '/api/admin', method: 'get', data: '', params: body})
},
// 根据id查询单个
queryAdminDetail(id) {
return request({url: '/api/admin' + id, method: 'get', data: '', params: ''})
},
// 添加
addAdmin(body) {
return request({url: '/api/admin', method: 'post', data: body, params: ''})
},
// 修改
editAdmin(body, id) {
return request({url: '/api/admin' + id, method: 'put', data: body, params: ''})
},
// 删除
deleteAdmin(ids) {
return request({url: '/api/admin' + ids.join(','), method: 'delete', data: '', params: ''})
},
}
<script setup>
import {api} from "./utils/vue-instance.js";
api().admin.queryAdmin()
</script>
下一步
- json-server模拟接口
- 代码生成器, 批量生成重复页面和代码
社群
你可以在这些平台联系我:
- bili: 刚子哥forever
- 企鹅群: 940263820
- gitee: gitee
- 博客: malcode-site
- 邮箱: malguy2022@163.com
- 知乎: 乐妙善哉居士
- csdn: 飞鸟malred
更多推荐


所有评论(0)