代码仓库

gitee之前写的
gitee本文案例, 请下载分支 5_other

创建项目

1
2
3
4
5
6
# 我个人比较喜欢用pnpm
npm create vite
pnpm create vite custom-vue-starter
# 敲命令后会弹出选项, 选vue 然后选JavaScript
cd custom-vue-starter
pnpm i

目录结构

一个完整的前端项目需要:

  • 状态管理
    在全局维护共有的状态(数据), 让页面组件之间共享数据, 我们使用pinia
  • 路由
    路由让页面之间可以进行跳转, 我们使用vue-router
  • 样式
    样式让页面更美观, 我们使用tailwindcss
  • 网络请求
    前端需要通过网络请求的方式和后端进行数据交互, 从而实现功能, 我们使用axios
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
├── 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/目录

1
2
# node的类型声明, 防止使用node的依赖后报错
pnpm i @types/node --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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'),
}
}
})

项目的设置

1
2
3
4
5
6
// settings.js
export default {
routeMode: 'history', // 路由模式
BaseURL: 'http://localhost:4000', // 后端请求地址
timeout: 5000, // 请求超时时间
}

PWA配置

1
2
# v1
pnpm i vite-plugin-pwa --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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'),
}
}
})

加载进度条

1
pnpm i nprogress

src/main.js

1
2
3
4
5
6
7

import 'nprogress/nprogress.css'
import progressBar from "./includes/progress-bar";
// 进度条
progressBar(router)

app.use(router)

includes/progress-bar

1
2
3
4
5
6
7
8
9
10
11
import NProgress from "nprogress";

export default (router) => {
router.beforeEach((to, from, next) => {
NProgress.start();
next();
});

router.afterEach(NProgress.done);
};

tailwind

使用tailwind3(如果你要用4的也可以, 不过两个安装有点区别)

1
2
pnpm install -D tailwindcss@3 postcss autoprefixer
pnpm dlx tailwindcss@3 init -p

tailwind.config.js

1
2
3
4
5
6
7
8
9
10
11
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

styles/base.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@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引入

1
2
3
4
5
6
7
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目录是普通的页面组件

1
2
# v4
pnpm i vue-router@4

文件路由

文件路由就是根据目录结构, 自动扫描并注册路由, 不需要我们一个一个手动声明注册
实现的关键是

1
import.meta.glob("xxx")

这是由vite提供的方法, 可以扫描获取文件, webpack也有类似的方法

1
require.context()

我们使用的是vite, 使用import.meta.glob就行, 现在来实现文件扫描功能

pages/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 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渲染子路由

1
2
3
4

<template>
<router-view/>
</template>

admin/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 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, 先简单渲染子路由, 后面组件的部分再完善管理系统骨架

1
2
3
4

<template>
<router-view/>
</template>

这两个代码是差不多的, 原理就是扫描目录, 然后还要处理一下嵌套的结构, 扫描处理完放入一个route数组, 这个route数组就是路由的定义,
可以统一注册到全局路由

现在看看路由的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 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中引入并使用

1
2
3
4
5
6
7
8
9
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实例上, 然后就可以全局使用了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 转换逻辑,将短横线转为驼峰再首字母大写
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中使用

1
2
3
4
5
6
7
8
9
10
11
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

1
2
3
4

<template>
hello
</template>

components/mal/red.vue

1
2
3
4

<template>
malred
</template>

pages/page.vue

1
2
3
4
5

<template>
<hello-world/>
<mal-red/>
</template>

element plus

之前admin/base.vue只简单渲染子路由的内容, 现在我们需要加入一些骨架, 比如左侧菜单, 顶部导航, 底部版权;
而这些骨架都是组件, 这些组件我们用element plus组件库来简化开发

1
2
# v2
pnpm i element-plus @element-plus/icons-vue

在src/main.js使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!--
* @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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<!--
* @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>


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<!--
* @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!--
* @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/*
* @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组件

还有一些管理页面的组件, 我们也写一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

<!-- 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<!--
* @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>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!--
* @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>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!--
* @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>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!--
* @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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<!--
* @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>


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<!--
* @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
<!--
* @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是国际化的常用库

1
2
# v9
pnpm i vue-i18n

src/includes/i18n.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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

1
2
3
import i18n from "@/includes/i18n.js";

app.use(i18n)

src/locales/cn.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"error": {
"nofound": "页面跑丢了~!"
},
"greet": "你好",
"info": {
"translate": {
"btn": {
"cn": "中文",
"en": "英文"
}
}
}
}

src/locales/en.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"error": {
"nofound": "404 NOT FOUND!"
},
"greet": "hello",
"info": {
"translate": {
"btn": {
"cn": "chinese",
"en": "english"
}
}
}
}

转换语言组件 src/components/translation-btn.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

<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实例上来全局使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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实例的方法

1
2
3
4
5
6
7
8
// vue-instance.js
import {getCurrentInstance} from "vue"

export const instance = () => getCurrentInstance()
.appContext.config.globalProperties

export const api = () => instance().$api
export const utils = () => instance().$utils
1
2
3
4
// main.js
import utils from '@/utils/index.js'

app.use(utils)

测试

1
2
3
4
// utils/test.js
export default {
test: () => console.log('test')
}
1
2
3
4
5
6
<!--app.vue-->
<script setup>
import {utils} from "./utils/vue-instance.js";

utils().test.test()
</script>

状态管理 store

之前学习vue的时候会发现原生的组件传值会遇到很麻烦的情况, 比如兄弟/跨级传值, 而我们可以使用全局状态管理的方案了解决
这里我们使用pinia

1
2
# v3
pnpm i pinia
1
2
3
4
5
6
7
8
9
10
11
12
13
// index.js
import {createPinia} from 'pinia'
import {useUserStore} from './user-store.js'

// 注册所有 store(可选)
// 这里只是为了确保所有 store 都被加载
export {
useUserStore
}

// 导出 pinia 实例供 main.js 使用
export default createPinia()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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))
}
1
2
3
4
// main.js
import store from "@/store/index.js";

app.use(store)

测试

1
2
3
4
5
6
7
8
9
10
11

<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来模拟测试

1
2
# v1
pnpm i axios

封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 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

1
2
3
4
// main.js
import api from '@/api/index.js'

app.use(api)

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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: ''})
},
}
1
2
3
4
5
6

<script setup>
import {api} from "./utils/vue-instance.js";

api().admin.queryAdmin()
</script>

下一步

  • json-server模拟接口
  • 代码生成器, 批量生成重复页面和代码

社群

你可以在这些平台联系我:


本站总访问量