Code Repository

Previous Gitee code templates
Gitee example for this article, please download branch 5_other

Creating a Project

1
2
3
4
5
6
# I personally prefer using pnpm
npm create vite
pnpm create vite custom-vue-starter
# After running the command, options will pop up. Select Vue and then JavaScript
cd custom-vue-starter
pnpm i

Directory Structure

A complete frontend project requires:

  • State Management
    Maintain common state (data) globally to share data between page components. We use Pinia.
  • Routing
    Routing allows navigation between pages. We use Vue Router.
  • Styling
    Styling makes the page more visually appealing. We use TailwindCSS.
  • Network Requests
    The frontend interacts with the backend through network requests to implement functionality. We use 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/               # Development environment build output directory
├── node_modules/ # Node.js dependencies directory
├── public/ # Static resources directory, not processed by the build tool
├── src/
├── admin/ # Contains backend management pages
├── api/ # API request logic
├── assets/ # Static resources (images, fonts, etc.)
├── components/ # Common components
├── includes/ # Include files, for external libraries
├── lib/ # Contains some common resources within the project
├── locales/ # Internationalization language packages
├── mocks/ # Mock data
├── pages/ # Contains regular page components
├── router/ # Router configuration
├── store/ # State management (Pinia)
├── styles/ # Global styles or CSS files
├── utils/ # Utility functions
├── App.vue # Root component
├── main.js # Project entry file
├── middleware.js # Middleware logic (e.g., route guards)
└── settings.js # Project settings or configuration files
├── .gitignore # Git ignore file configuration
├── index.html # Project entry HTML file
├── package.json # Project configuration and dependencies declaration
├── postcss.config.js # PostCSS configuration file
├── README.md # Project documentation
├── tailwind.config.js # Tailwind CSS configuration file
├── vite.config.js # Vite build tool configuration file

Configuration

Path Aliases

Configure path aliases to use @/ to represent the src/ directory.

1
2
# Node type declarations to prevent errors after using Node dependencies
pnpm i @types/node --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
// Path aliases
resolve: {
alias: {
'@': join(__dirname, 'src'),
}
}
})

Project Settings

1
2
3
4
5
export default {
routeMode: 'history', // Router mode
BaseURL: 'http://localhost:4000', // Backend request address
timeout: 5000, // Request timeout
}

PWA Configuration

1
2
# v2
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
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: {
// Generate manifest file
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'),
}
}
})

Loading Progress Bar

1
pnpm i nprogress

src/main.js

1
2
3
4
5
import 'nprogress/nprogress.css'
import progressBar from "./includes/progress-bar";
// Progress bar
progressBar(router)
app.use(router)

includes/progress-bar

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

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

Tailwind

Using Tailwind 3 (if you want to use version 4, there are some installation differences)

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
@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;
}

/* Override default maximum width limit */
@media (min-width: 1536px) {
.container {
max-width: 100%;
}
}

/* Main content of the page */
.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;
}
}

/* Set print control styles */
.plugin-download {
width: 500px !important;

a:hover {
text-decoration: underline;
}
}

Remember to import in src/main.js

1
2
3
4
5
6
import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'

createApp(App)
.mount('#app')

Router

Using Vue Router for route navigation, and we will implement file routing, automatically scanning the page.vue files
in the directory and registering them as routes. There are two page directories: one is admin, and the other is
pages. The admin directory is for backend management pages, and the pages directory is for regular page
components.

1
2
# v4
pnpm i vue-router@4

File Routing

File routing automatically scans and registers routes based on the directory structure, eliminating the need to manually
declare and register each one. The key is:

1
import.meta.glob("xxx")

This is a method provided by Vite to scan and retrieve files. Webpack has a similar method:

1
require.context()

We are using Vite, so we use import.meta.glob. Now, let’s implement the file scanning functionality.
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
// src/pages/index.js
// Scan all page.vue files in the pages directory
const pages = import.meta.glob('./**/page.vue')
// First, scan for a flat structure
const routes = Object.keys(pages).map(key => {
const path = key
.replace(/^\.\//, '') // Remove ./
.replace(/\/?page\.vue$/, ''); // Remove /page.vue
return {
path: `/${path}`,
name: path,
component: () => pages[key](),
meta: {
title: path.split('/').pop() || 'Home'
},
hidden: false,
whiteList: true
};
});
// Insert parent nodes
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) {
// Make the parent path of /a/b, such as /a, also appear in the routes, supporting menu hierarchy structure
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 renders child routes

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
// src/admin/index.js
const pages = import.meta.glob('./**/page.vue')
// First, scan for a flat structure
const routes = Object.keys(pages).map(key => {
const path = key
.replace(/^\.\//, '') // Remove ./
.replace(/\/?page\.vue$/, ''); // Remove /page.vue
// Extract parent path
// const parentPath = path.substring(0, path.lastIndexOf('/'));
return {
path: path, // Use relative path to be nested as child routes
// path: `/${path}`,
name: path,
component: () => pages[key](),
meta: {
title: path.split('/').pop() || 'Home'
},
hidden: false,
whiteList: true
};
});
// Insert parent nodes
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) {
// Make the parent path of /a/b, such as /a, also appear in the routes, supporting menu hierarchy structure
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, first simply render child routes, and then improve the management system skeleton in the component
section

1
2
3
4

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

These codes are similar, the principle is to scan the directory and then process the nested structure. After scanning
and processing, put them into a route array, which is the definition of the route, and can be uniformly registered to
the global route.
Now, let’s look at the route code

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
// 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 points to the base configuration in vite.config.js
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()
}
},
// Scan vue files under pages
...routes,
// Scan vue files under admin
{
name: 'admin',
path: '/admin',
component: () => import('@/admin/base.vue'),
children: [
...adminRoutes
]
},
{
path: '/:catchAll(.*)*',
// If none of the above match, redirect to 404
redirect: {name: '404'}
}
]
})
// Global route guard
router.beforeEach((to, from, next) => {
console.log('Global Guard')
console.log(to, from)
// If the route metadata has a title, set document.title
if (to.meta && to.meta.title) {
document.title = to.meta.title
}
next()
})
export default router

Remember to import and use in src/main.js

1
2
3
4
5
6
7
8
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')

At this point, you can create test files in pages and admin, such as pages/a/b/c/page.vue, and then start the project to
access it.

Components

We place components in the components directory, and automatically scan and register them into the Vue instance through
the index.js in this directory, so they can be used globally.

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
// Conversion logic, converting hyphens to camelCase and then capitalizing the first letter
function toPascalCase(str) {
return str
.split('-')
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join('')
}

export default {
install: (app) => {
// Synchronously scan all .vue files in the current directory
const components = import.meta.glob('./**/*.vue', {eager: true})
for (const path in components) {
const componentName = path
.replace(/^\.\//, '')
.replace(/\.\w+$/, '')
.split('/')
.map(toPascalCase)
.join('')
// Get component definition (synchronous)
const component = components[path].default
// Register as a global component
app.component(componentName, component)
}
}
}

Here, install is the Vue plugin logic, and the install method will be automatically called when the Vue instance
uses use.
Use in src/main.js

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

Testing:

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

Earlier, admin/base.vue only simply rendered the content of child routes. Now, we need to add some skeletons, such as
the left menu, top navigation, and bottom copyright; these skeletons are components, and we use the Element Plus
component library to simplify development.

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

Use in src/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)
// Globally install 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 Components

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
<!--
* @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
<!--
* @Author Malred
* @Date 2025-06-01 06:30:07
* @Description Sidebar navigation
* @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>
<!-- Menu -->
<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
<!--
* @Author Malred
* @Date 2025-06-01 06:52:12
* @Description Top navigation
* @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="/">Home</el-menu-item>
<el-sub-menu index="/about" popper-append-to-body>
<template #title>About</template>
<el-menu-item index="/about/team">Team Introduction</el-menu-item>
<el-menu-item index="/about/contact">Contact Us</el-menu-item>
</el-sub-menu>
<el-menu-item index="/services">Services</el-menu-item>
</el-menu>
<!-- Right tools, use margin-left: auto to push to the right -->
<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; /* Key: push the entire div to the right */
}
</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
<!--
* @Author Malred · Wang
* @Date 2025-06-17 11:26:20
* @Description
* @Path src/admin/base.vue
-->
<template>
<el-container style="height: 100vh; width: 100vw;">
<!-- Left sidebar -->
<el-aside width="200px" style="background-color: #409EFF; height: 100vh;">
<layout-aside/>
</el-aside>
<!-- Main content -->
<el-container style="flex: 1; min-width: 0;">
<!-- Header -->
<el-header style="background-color: #4fa3fa; height: 60px; display: flex; align-items: center;">
<layout-header/>
</el-header>
<!-- Content area -->
<el-main style="margin: 0; padding: 0; height: calc(100vh - 60px);">
<router-view/>
</el-main>
<!-- Footer -->
<el-footer style="display: flex; justify-content: center; align-items: center;">
Copyright © Malred
</el-footer>
</el-container>
</el-container>
</template>
<script setup>
</script>
<style scoped>
.el-main {
--el-main-padding: 0px;
}
</style>

Menu Tree Building Utility

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
/*
* @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 = {};
// First map all routes to map
routes.forEach(route => {
map[route.path] = {...route, children: []};
});
// Build tree structure
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 Components

There are also some components for managing pages that we’ll write as well.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 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
<!--
* @Author Malred
* @Date 2025-06-02 05:13:16
* @Description Dialog component
* @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">Cancel</el-button>
<el-button type="primary" @click="submit">Submit</el-button>
</template>
</el-dialog>
</template>
<script setup>
import {ref} from 'vue';
// Define emits
const emit = defineEmits(['submit']);
// Control dialog visibility
const visible = ref(false);
// Show method
const show = () => {
visible.value = true;
};
// Submit method
const submit = () => {
emit('submit'); // Trigger submit event
visible.value = false;
};
// Expose methods to parent component
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
<!--
* @Author Malred
* @Date 2025-06-02 06:18:53
* @Description Date selection search component
* @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="Select date"
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
<!--
* @Author Malred
* @Date 2025-06-02 05:12:31
* @Description Normal input search box
* @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="Please enter"/>
</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
<!--
* @Author Malred
* @Date 2025-06-02 06:18:42
* @Description Dropdown select search box
* @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="Please select">
<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
<!--
* @Author Malred
* @Date 2025-06-02 05:10:43
* @Description Table header component
* @Path src/components/base/table/header.vue
-->
<!-- base-table-header.vue -->
<template>
<div class="table-header">
<!-- Slots can insert any structure freely, such as el-form -->
<div class="search-container">
<slot name="search"></slot>
</div>
<div class="actions">
<slot name="action">
<el-button type="primary" @click="$emit('add')">New</el-button>
<el-button
type="danger"
@click="onDeleteSelected"
:disabled="deleteDisabled"
>
Delete Selected
</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; /* Vertically centered */
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
<!--
* @Author Malred
* @Date 2025-06-02 05:11:31
* @Description Table body component
* @Path src/components/base/table/body.vue
-->
<template>
<el-table :data="data" border style="width: 100%" @selection-change="handleSelectionChange">
<!-- Multi-select column -->
<el-table-column type="selection" width="55"></el-table-column>
<!-- Dynamic columns -->
<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>
<!-- Action column -->
<el-table-column label="Actions" width="150">
<template #default="scope">
<slot name="operation" :row="scope.row">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">Edit</el-button>
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">Delete</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: { // New prop, receive delete method
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>

Testing: 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
<!--
* @Author Malred · Wang
* @Date 2025-06-17 13:27:45
* @Description Test table component
* @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>
<!-- Text input -->
<base-table-search-item label="Username" v-model="searchForm.username"/>
<!-- Email input -->
<base-table-search-item label="Email" v-model="searchForm.email"/>
<!-- ID input -->
<base-table-search-item label="ID" v-model="searchForm.id"/>
<!-- Status select -->
<base-table-search-select
label="Status"
v-model="searchForm.status"
:options="[
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' }
]"
/>
<!-- Creation time -->
<base-table-search-date label="Creation Time" v-model="searchForm.createTime"/>
<!-- Clear button -->
<el-button type="info" size="small" @click="resetSearchForm">Reset</el-button>
</template>
</base-table-header>
<base-table-body
:data="tableData"
:columns="columns"
@selection-change="handleSelectionChange"
@edit="openEditDialog"
@delete="handleDelete"
:onDelete="handleDelete"
>
<!-- Custom action column -->
<template #operation="props">
<el-button link type="primary" size="small" @click="openEditDialog(props.row)">Edit</el-button>
<el-button link type="danger" size="small" @click="handleDelete([props.row])">Delete</el-button>
</template>
</base-table-body>
<!-- Dialog part -->
<base-operation ref="operationDialog" title="User Information" @submit="submitForm">
<base-operation-item
label="Username"
:value="formData.username"
@update:value="val => formData.username = val"
/>
<base-operation-item
label="Email"
:value="formData.email"
@update:value="val => formData.email = val"
/>
</base-operation>
</div>
</div>
</template>
<script setup>
import {ref, watch} from 'vue';
// Data source
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]);
// Table column configuration
const columns = [
{prop: 'id', label: 'ID'},
{prop: 'username', label: 'Username'},
{prop: 'email', label: 'Email'},
{prop: 'status', label: 'Status'},
{prop: 'createTime', label: 'Creation Time'},
];
// Search form
const searchForm = ref({
username: '',
email: '',
id: '',
status: '',
createTime: ''
});
// Reset search form
const resetSearchForm = () => {
searchForm.value = {
username: '',
email: '',
id: '',
status: '',
createTime: ''
};
};
// Add/edit form data
const formData = ref({
username: '',
email: '',
});
const operationDialog = ref(null);
// Open add dialog
const openAddDialog = () => {
formData.value = {username: '', email: ''};
operationDialog.value.show();
};
// Open edit dialog
const openEditDialog = (row) => {
formData.value = {...row};
operationDialog.value.show();
};
// Submit add/edit
const submitForm = () => {
if (!formData.value.id) {
// Add
const newRow = {
id: Date.now(),
username: formData.value.username,
email: formData.value.email
};
tableData.value.unshift(newRow);
} else {
// Edit
const index = tableData.value.findIndex(item => item.id === formData.value.id);
if (index > -1) {
tableData.value.splice(index, 1, {...formData.value});
}
}
};
// Multi-select rows
const selectedRows = ref([]);
const handleSelectionChange = (rows) => {
selectedRows.value = rows;
};
// Delete
const handleDelete = (rows) => {
tableData.value = tableData.value.filter(row =>
!rows.some(r => r.id === row.id)
);
};
// Batch delete
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 search changes
watch(
() => searchForm.value,
(newVal) => {
tableData.value = applySearch(originalData, newVal);
},
{deep: true}
);
</script>
<style scoped>
.search-form {
display: flex;
align-items: center; /* Key: vertically centered */
gap: 12px; /* Optional: increase spacing between items */
}
</style>

i18n Switch Button

i18n is a commonly used library for internationalization.

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
import {createI18n} from "vue-i18n";

export const messages = {};
// Scan the locales directory and register i18n language packages
const modules = import.meta.glob('../locales/*.json'); // Only read JSON files in the current directory, not recursive subdirectories
for (const path in modules) {
const key = path.match(/\.\.\/locales\/([^.]+)\.json$/)?.[1];
if (key) {
const module = await modules[path]();
messages[key] = module.default; // Note the .default here
}
console.log(key) // en
console.log(path)
}
console.log('messages', messages)
export default createI18n({
legacy: false, // Vue 3 Composition API mode
locale: 'cn', // Default language
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": "Page is lost~!"
},
"greet": "Hello",
"info": {
"translate": {
"btn": {
"cn": "Chinese",
"en": "English"
}
}
}
}

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"
}
}
}
}

Translation button component 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

<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()
// Dynamically generate language options
const options = computed(() => [
{
label: t('info.translate.btn.en'),
key: 'en'
},
{
label: t('info.translate.btn.cn'),
key: 'cn'
}
]);
// Switch language
const handleSelect = (key) => {
locale.value = String(key)
}
</script>
<style scoped>
/* Optional: Adjust button styles */
.el-button {
border: none;
padding: 0;
width: 36px;
height: 36px;
}
</style>

Common Methods utils

Place utility functions in the utils folder, and we will mount them to the Vue instance globally by scanning the JS
files in this directory and then mounting them to the Vue instance.

1
2
3
4
5
6
7
8
9
10
11
12
13
// index.js
const modules = import.meta.glob('./*.js'); // Note: Only read JS files in the current directory, not recursive
const utils = {};
for (const path in modules) {
const moduleName = path.replace('./', '').replace('.js', '');
const module = await modules[path](); // Get the module at the corresponding path
utils[moduleName] = module.default; // Extract the default export
}
export default {
install: (app) => {
app.config.globalProperties.$utils = utils;
},
};

Getting the Vue 3 instance method

1
2
3
4
5
6
7
// 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)

Testing

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>

State Management store

When learning Vue, you will find that native component value passing can be quite troublesome, especially for
sibling/cross-level value passing. We can use a global state management solution to solve this. Here, we use Pinia.

1
2
# v3
pnpm i pinia
1
2
3
4
5
6
7
8
9
10
// index.js
import {createPinia} from 'pinia'
import {useUserStore} from './user-store.js'
// Register all stores (optional)
// Here, it is just to ensure all stores are loaded
export {
useUserStore
}
// Export the Pinia instance for use in main.js
export default createPinia()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// user-store.js
import {defineStore, acceptHMRUpdate} from 'pinia'

export const useUserStore = defineStore('user', {
state: () => ({
name: 'Zhang San',
age: 25
}),
// getters: {},
actions: {
setName(newName) {
this.name = newName
}
}
})
// Support Pinia hot updates, no need to refresh the browser
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)

Testing

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>

Network Request API

Using Axios for network requests, in most cases, front-end and back-end development are done by different teams. The
front-end request interface address is provided by the back-end. If the back-end does not have an interface yet, you
need to use fake data or json-server for simulation testing.

1
2
# v1
pnpm i axios

Encapsulation

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
// request.js
import axios from 'axios'
import settings from "../settings.js";

const request = axios.create({
baseURL: settings.BaseURL, // Set base URL
timeout: settings.timeout, // Request timeout
})
// Request interceptor
request.interceptors.request.use(
(config) => {
// Do something before sending the request, such as adding a token
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
(error) => {
// Do something with request error
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
(response) => {
// Handle response data
return response.data
},
(error) => {
// Handle response error
return Promise.reject(error)
}
)
export default request

Scan and register to Vue instance

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
// index.js
const modules = import.meta.glob('./**/index.js') // Only scan index.js files
const requests = {}
for (const path in modules) {
if (path === './index.js') continue // Skip self
const modulePath = path
.replace('./', '')
.replace('/index.js', '') // Remove prefix and suffix
const segments = modulePath.split('/') // Split path levels
// Safely get module name: if it's a single-level directory (e.g., admin/index), take the first segment directly
const moduleName = segments[segments.length - 1]
console.log('api', moduleName)
console.log('api', segments)
let currentLevel = requests
// Build nested structure
// 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]
}
// Load module and mount to corresponding position
// Need to add await, otherwise these functions will be empty
await modules[path]().then(module => {
console.log('currentLevel', currentLevel)
console.log('moduleName', moduleName)
// currentLevel[moduleName] = module.default
// Then directly assign to currentLevel (no longer use moduleName)
Object.assign(currentLevel, module.default)
// currentLevel = module.default
})
}
export default {
install: (app) => {
// app.config.globalProperties.$requests = requests
app.config.globalProperties.$api = requests
},
}

Add to main.js

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

app.use(api)

Testing

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 {
// Query all
queryAdmin(body) {
return request({url: '/api/admin', method: 'get', data: '', params: body})
},
// Query by id
queryAdminDetail(id) {
return request({url: '/api/admin' + id, method: 'get', data: '', params: ''})
},
// Add
addAdmin(body) {
return request({url: '/api/admin', method: 'post', data: body, params: ''})
},
// Edit
editAdmin(body, id) {
return request({url: '/api/admin' + id, method: 'put', data: body, params: ''})
},
// Delete
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>

Next Steps

  • json-server to simulate interfaces
  • Code generator, batch generate repetitive pages and code

Community

You can contact me on these platforms:


本站总访问量