代码仓库
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'
export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': join(__dirname, 'src'), } } })
|
项目的设置
1 2 3 4 5 6
| 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";
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'), } } })
|
加载进度条
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
| 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
|
文件路由
文件路由就是根据目录结构, 自动扫描并注册路由, 不需要我们一个一个手动声明注册
实现的关键是
这是由vite提供的方法, 可以扫描获取文件, webpack也有类似的方法
我们使用的是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
|
const pages = import.meta.glob('./**/page.vue')
const routes = Object.keys(pages).map(key => { const path = key .replace(/^\.\//, '') .replace(/\/?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 => { let currentPath = path.substring(0, path.lastIndexOf('/')); console.log('currentPath', currentPath)
while (currentPath) { if (!parentRoutes.has(currentPath)) { routes.push({ path: currentPath, name: currentPath.replace(/\//g, '-'), meta: {title: currentPath.split('/').pop()} }); parentRoutes.add(currentPath); } 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
|
const pages = import.meta.glob('./**/page.vue')
const routes = Object.keys(pages).map(key => { const path = key .replace(/^\.\//, '') .replace(/\/?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 => { let currentPath = path.substring(0, path.lastIndexOf('/')); console.log('currentPath', currentPath)
while (currentPath) { if (!parentRoutes.has(currentPath)) { routes.push({ path: currentPath, name: currentPath.replace(/\//g, '-'), meta: {title: currentPath.split('/').pop()} }); parentRoutes.add(currentPath); } 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
|
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({ 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() } }, ...routes, { name: 'admin', path: '/admin', component: () => import('@/admin/base.vue'), children: [ ...adminRoutes ] }, { path: '/:catchAll(.*)*', redirect: {name: '404'} } ] })
router.beforeEach((to, from, next) => { console.log('Global Guard') console.log(to, from) 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) => { 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)
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
|
<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
|
<template> <div class="aside-container"> <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.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
|
<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>
<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; } </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
|
<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
|
export function buildMenuTree(routes) { console.log('build menu routes', routes) const tree = []; const 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
|
<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
|
<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';
const emit = defineEmits(['submit']);
const visible = ref(false);
const show = () => { visible.value = true; };
const submit = () => { emit('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
|
<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; 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
|
<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; 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
|
<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;
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
|
<template> <div class="table-header"> <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
|
<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: { 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
|
<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"/>
<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是国际化的常用库
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 = {};
const modules = import.meta.glob('../locales/*.json');
for (const path in modules) { const key = path.match(/\.\.\/locales\/([^.]+)\.json$/)?.[1]; if (key) { const module = await modules[path](); messages[key] = module.default; } console.log(key) console.log(path) }
console.log('messages', messages)
export default createI18n({ legacy: false, 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
| const modules = import.meta.glob('./*.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
| 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
| import utils from '@/utils/index.js'
app.use(utils)
|
测试
1 2 3 4
| export default { test: () => console.log('test') }
|
1 2 3 4 5 6
| <script setup> import {utils} from "./utils/vue-instance.js";
utils().test.test() </script>
|
状态管理 store
之前学习vue的时候会发现原生的组件传值会遇到很麻烦的情况, 比如兄弟/跨级传值, 而我们可以使用全局状态管理的方案了解决
这里我们使用pinia
1 2 3 4 5 6 7 8 9 10 11 12 13
| import {createPinia} from 'pinia' import {useUserStore} from './user-store.js'
export { useUserStore }
export default createPinia()
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import {defineStore, acceptHMRUpdate} from 'pinia'
export const useUserStore = defineStore('user', { state: () => ({ name: '张三', age: 25 }), actions: { setName(newName) { this.name = newName } } })
if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) }
|
1 2 3 4
| 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 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
| import axios from 'axios' import settings from "../settings.js";
const request = axios.create({ baseURL: settings.BaseURL, timeout: settings.timeout, })
request.interceptors.request.use( (config) => { 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
| const modules = import.meta.glob('./**/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('/')
const moduleName = segments[segments.length - 1] console.log('api', moduleName) console.log('api', segments)
let currentLevel = requests
for (let i = 0; i < segments.length; i++) { const segment = segments[i] currentLevel[segment] = currentLevel[segment] || {} currentLevel = currentLevel[segment] }
await modules[path]().then(module => { console.log('currentLevel', currentLevel) console.log('moduleName', moduleName)
Object.assign(currentLevel, module.default) }) }
export default { install: (app) => { app.config.globalProperties.$api = requests }, }
|
添加到main.js
1 2 3 4
| 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
| import request from "../request.js";
export default { queryAdmin(body) { return request({url: '/api/admin', method: 'get', data: '', params: body}) }, 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模拟接口
- 代码生成器, 批量生成重复页面和代码
社群
你可以在这些平台联系我: