代码仓库

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

创建项目

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

目录结构

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

  • 状态管理
    在全局维护共有的状态(数据), 让页面组件之间共享数据, 我们使用pinia
  • 路由
    路由让页面之间可以进行跳转, 我们使用vue-router
  • 样式
    样式让页面更美观, 我们使用tailwindcss
  • 网络请求
    前端需要通过网络请求的方式和后端进行数据交互, 从而实现功能, 我们使用axios
├── dev-dist/               # 开发环境构建输出目录
├── node_modules/           # Node.js依赖包目录
├── public/                 # 静态资源目录,不会被构建工具处理
├── src/
    ├── admin/                  # 存放后台管理页面
    ├── api/                    # 接口请求逻辑
    ├── assets/                 # 静态资源(图片、字体等)
    ├── components/             # 公共组件
    ├── includes/               # 包含文件,存放外部库
    ├── lib/                    # 存放项目内的一些公共资源
    ├── locales/                # 国际化语言包
    ├── mocks/                  # 模拟数据
    ├── pages/                  # 存放普通的页面组件
    ├── router/                 # 路由配置
    ├── store/                  # 状态管理(Pinia)
    ├── styles/                 # 全局样式或CSS文件
    ├── utils/                  # 工具函数
    ├── App.vue                 # 根组件
    ├── main.js                 # 项目入口文件
    ├── middleware.js           # 中间件逻辑(如路由守卫)
    └── settings.js             # 项目设置或配置文件
├── .gitignore              # Git忽略文件配置
├── index.html              # 项目入口HTML文件
├── package.json            # 项目配置及依赖声明
├── postcss.config.js       # PostCSS配置文件
├── README.md               # 项目说明文档
├── tailwind.config.js      # Tailwind CSS配置文件
├── vite.config.js          # Vite构建工具配置文件

配置

路径别名

配置一下路径别名, 用@/表示src/目录

# node的类型声明, 防止使用node的依赖后报错
pnpm i @types/node --save-dev
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'

// https://vite.dev/config/
export default defineConfig({
    plugins: [vue()],
    // 路径别名
    resolve: {
        alias: {
            '@':
                join(__dirname, 'src'),
        }
    }
})

项目的设置


export default {
    routeMode: 'history', // 路由模式
    BaseURL: 'http://localhost:4000', // 后端请求地址
    timeout: 5000, // 请求超时时间
}

PWA配置

# v2
pnpm i vite-plugin-pwa --save-dev
import {defineConfig} from 'vite'
import {join} from 'path';
import vue from '@vitejs/plugin-vue'
import {VitePWA} from "vite-plugin-pwa";

// https://vite.dev/config/
export default defineConfig({
    plugins: [
        vue(),
        VitePWA({
            registerType: 'autoUpdate',
            devOptions: {
                // 生成清单文件
                enabled: true
            },
            manifest: {
                name: "vue-quick-start",
                theme_color: '#ff5e3a',
                icons: [
                    {
                        src: 'assets/logo.png',
                        size: '192x192',
                        type: 'image/png'
                    }
                ]
            },
            workbox: {
                globPatterns: ['**/*.{js,css,html,png,jpg}']
            }
        })
    ],
    resolve: {
        alias: {
            '@':
                join(__dirname, 'src'),
        }
    }
})

加载进度条

pnpm i nprogress

src/main.js


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

app.use(router)

includes/progress-bar

import NProgress from "nprogress";

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

    router.afterEach(NProgress.done);
};

tailwind

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

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

tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
    content: [
        "./index.html",
        "./src/**/*.{vue,js,ts,jsx,tsx}",
    ],
    theme: {
        extend: {},
    },
    plugins: [],
}

styles/base.css

@tailwind base;

@layer base {
    h1 {
        @apply text-2xl;
    }

    h2 {
        @apply text-xl;
    }

    h3 {
        @apply text-lg;
    }

    h4 {
        @apply text-base;
    }

    h5 {
        @apply text-sm;
    }

    h6 {
        @apply text-xs;
    }
}

@tailwind components;
@tailwind utilities;

body {
    @apply h-full w-full p-0 m-0;
}

/* 覆盖默认的最大宽度限制 */
@media (min-width: 1536px) {
    .container {
        max-width: 100%;
    }
}

/* 页面主内容 */
.container {
    width: 100%;
    height: 100%;
    background-color: #f5f7fb;
    padding: 20px;

    .container-wrapper {
        width: 100%;
        height: 100%;
        padding: 15px 30px 0;
        background-color: #fff;
        border-radius: 8px;
        display: flex;
        flex-direction: column;
        overflow: hidden;
    }
}

/* 设置打印控件样式 */
.plugin-download {
    width: 500px !important;

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

记得在src/main.js引入

import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'

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

路由 router

使用vue-router进行路由跳转, 并且我们会实现文件路由, 自动扫描目录下的page.vue文件, 然后注册为路由,
有两个页面目录, 一个是admin, 一个是pages, admin目录是后台管理页面, pages目录是普通的页面组件

# v4
pnpm i vue-router@4

文件路由

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

import.meta.glob("xxx")

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

require.context()

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

pages/index.js

// src/pages/index.js

// 扫描pages目录下所有page.vue
const pages = import.meta.glob('./**/page.vue')

// 先扫描出扁平结构
const routes = Object.keys(pages).map(key => {
    const path = key
        .replace(/^\.\//, '') // 去掉 ./
        .replace(/\/?page\.vue$/, ''); // 去掉 /page.vue

    return {
        path: `/${path}`,
        name: path,
        component: () => pages[key](),
        meta: {
            title: path.split('/').pop() || '首页'
        },
        hidden: false,
        whiteList: true
    };
});

// 插入父级节点
const allPaths = routes.map(route => route.path);
const parentRoutes = new Set();

allPaths.forEach(path => {
    // /a/b/c -> /a/b
    let currentPath = path.substring(0, path.lastIndexOf('/'));
    console.log('currentPath', currentPath)

    while (currentPath) {
        // 让 /a/b 的父路径 /a 也出现在路由中,从而支持菜单层级结构
        if (!parentRoutes.has(currentPath)) {
            routes.push({
                // /a
                path: currentPath,
                // name: -a
                name: currentPath.replace(/\//g, '-'),
                meta: {title: currentPath.split('/').pop()}
            });
            parentRoutes.add(currentPath);
        }
        // /a/b -> /a
        currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
    }
});

console.log('scan routes', routes)
export default routes

app.vue渲染子路由


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

admin/index.js

// src/admin/index.js

const pages = import.meta.glob('./**/page.vue')

// 先扫描出扁平结构
const routes = Object.keys(pages).map(key => {
    const path = key
        .replace(/^\.\//, '') // 去掉 ./
        .replace(/\/?page\.vue$/, ''); // 去掉 /page.vue

    // 提取父路径
    // const parentPath = path.substring(0, path.lastIndexOf('/'));

    return {
        path: path, // 使用相对路径, 才能放入children里被嵌套为子路由
        // path: `/${path}`,
        name: path,
        component: () => pages[key](),
        meta: {
            title: path.split('/').pop() || '首页'
        },
        hidden: false,
        whiteList: true
    };
});

// 插入父级节点
const allPaths = routes.map(route => route.path);
const parentRoutes = new Set();

allPaths.forEach(path => {
    // /a/b/c -> /a/b
    let currentPath = path.substring(0, path.lastIndexOf('/'));
    console.log('currentPath', currentPath)

    while (currentPath) {
        // 让 /a/b 的父路径 /a 也出现在路由中,从而支持菜单层级结构
        if (!parentRoutes.has(currentPath)) {
            routes.push({
                // /a
                path: currentPath,
                // name: -a
                name: currentPath.replace(/\//g, '-'),
                meta: {title: currentPath.split('/').pop()}
            });
            parentRoutes.add(currentPath);
        }
        // /a/b -> /a
        currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
    }
});

console.log('scan admin routes', routes)
export default routes

admin/base.vue, 先简单渲染子路由, 后面组件的部分再完善管理系统骨架


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

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

现在看看路由的代码

// src/router/index.js

import {createRouter, createWebHashHistory, createWebHistory} from 'vue-router'
import routes from "@/pages/index.js";
import adminRoutes from '@/admin/index.js'
import settings from "../settings.js";

const router = createRouter({
    // import.meta.env.BASE_URL 指向 vite.config.js 中的 base 配置
    history: settings.routeMode === 'history'
        ? createWebHistory(import.meta.env.BASE_URL)
        : createWebHashHistory(import.meta.env.BASE_URL),
    routes: [
        {
            path: '/about',
            name: 'about',
            component: () => import('@/pages/about/page.vue')
        },
        {
            path: '/login',
            name: 'login',
            component: () => import('@/pages/login/page.vue')
        },
        {
            path: '/404',
            name: '404',
            component: () => import('@/pages/404.vue'),
            beforeEnter: (to, from, next) => {
                console.log('Router Guard')
                console.log(to, from)

                next()
            }
        },
        // 扫描pages下vue
        ...routes,
        // 扫描admin下vue
        {
            name: 'admin',
            path: '/admin',
            component: () => import('@/admin/base.vue'),
            children: [
                ...adminRoutes
            ]
        },
        {
            path: '/:catchAll(.*)*',
            // 上面的都匹配不到,就重定向到404
            redirect: {name: '404'}
        }
    ]
})

// 全局路由守卫
router.beforeEach((to, from, next) => {
    console.log('Global Guard')
    console.log(to, from)
    // 如果路由元信息中有 title,则设置 document.title
    if (to.meta && to.meta.title) {
        document.title = to.meta.title
    }
    next()
})

export default router

记得在src/main.js中引入并使用

import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'

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

此时你可以在pages和admin下创建测试文件, 比如pages/a/b/c/page.vue, 然后启动项目访问

组件 components

我们在components目录下存放组件, 通过该目录下的index.js自动扫描并注册到vue实例上, 然后就可以全局使用了

// 转换逻辑,将短横线转为驼峰再首字母大写
function toPascalCase(str) {
    return str
        .split('-')
        .map(p => p.charAt(0).toUpperCase() + p.slice(1))
        .join('')
}

export default {
    install: (app) => {
        // 同步扫描当前目录下的所有 .vue 文件
        const components = import.meta.glob('./**/*.vue', {eager: true})

        for (const path in components) {
            const componentName = path
                .replace(/^\.\//, '')
                .replace(/\.\w+$/, '')
                .split('/')
                .map(toPascalCase)
                .join('')

            // 获取组件定义(同步)
            const component = components[path].default

            // 注册为全局组件
            app.component(componentName, component)
        }
    }
}

这里的install是vue插件逻辑, vue实例use的时候会自动调用install方法

在src/main.js中使用

import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
import components from "@/components/index.js";

createApp(App)
    .use(router)
    .use(components)
    .mount('#app')

测试:

components/hello-world.vue


<template>
    hello
</template>

components/mal/red.vue


<template>
    malred
</template>

pages/page.vue


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

element plus

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

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

在src/main.js使用

import {createApp} from 'vue'
import '@/styles/base.css'
import App from './App.vue'
import router from '@/router/index.js'
import components from "@/components/index.js";
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)

app.use(router)

// 全局安装element+ icons
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
}

app.use(ElementPlus)
app.use(components)

app.mount('#app')

layout组件

<!--
 * @Author Malred
 * @Date 2025-06-01 22:28:33
 * @Description
 * @Path src/components/layout/aside/menu-item.vue
 -->
<template>
    <div :key="item.path">
        <el-menu-item
                v-if="!item.children || !item.children.length"
                :index="item.path"
        >
            {{ item.meta.title || item.name }}
        </el-menu-item>

        <el-sub-menu v-else :index="item.path" :key="item.path">
            <template #title>{{ item.meta.title || item.name }}</template>
            <MenuItem v-for="child in item.children" :key="child.path" :item="child"/>
        </el-sub-menu>
    </div>
</template>

<script>
    export default {
        name: 'MenuItem',
        props: ['item']
    };
</script>


<style scoped>

</style>
<!--
 * @Author Malred
 * @Date 2025-06-01 06:30:07
 * @Description 侧边导航
 * @Path src/components/layout/aside.vue
 -->
<template>
    <div class="aside-container">
        <!-- Logo -->
        <div class="logo-section">
            <img src="../../assets/logo.png" alt="Logo" class="logo-img"/>
            <span class="logo-title">Malred</span>
        </div>

        <!-- 菜单 -->
        <el-menu
                :default-active="$route.path"
                class="el-menu-vertical-demo"
                background-color="#409EFF"
                text-color="white"
                active-text-color="#ffd04b"
                :router="true"
        >
            <menu-item v-for="item in menuTree" :key="item.path" :item="item"/>
        </el-menu>
    </div>
</template>

<script>
    import routes from '@/admin/index';
    import {buildMenuTree} from '@/utils/menu';
    import MenuItem from "./aside/menu-item.vue";

    export default {
        components: {
            MenuItem
        },
        data() {
            return {
                // menuTree: buildMenuTree(routes)
                menuTree: buildMenuTree(routes.map(item => ({
                    ...item,
                    path: '/admin/' + item.path
                })))
            };
        },
        mounted() {
            console.log(this.menuTree)
        }
    };
</script>


<style scoped>
    .aside-container {
        height: 100%;
    }

    .logo-section {
        background-color: #4fa3fa;
        display: flex;
        align-items: center;
        padding: 14px;
        color: white;
    }

    .logo-img {
        width: 32px;
        height: 32px;
        margin-right: 10px;
    }

    .logo-title {
        font-size: 18px;
        font-weight: bold;
    }
</style>


<!--
 * @Author Malred
 * @Date 2025-06-01 06:52:12
 * @Description 顶部导航
 * @Path src/components/layout/header.vue
 -->
<template>
    <div class="header-container">
        <el-menu
                mode="horizontal"
                background-color="#4fa3fa"
                text-color="white"
                active-text-color="#ffd04b"
                :router="true"
        >
            <el-menu-item index="/">首页</el-menu-item>
            <el-sub-menu index="/about" popper-append-to-body>
                <template #title>关于</template>
                <el-menu-item index="/about/team">团队介绍</el-menu-item>
                <el-menu-item index="/about/contact">联系我们</el-menu-item>
            </el-sub-menu>
            <el-menu-item index="/services">服务</el-menu-item>
        </el-menu>

        <!-- 右侧工具栏,使用 margin-left: auto 推到最右 -->
        <div class="right-tools">
            <translation-btn style="margin-right: 16px;"/>
            <el-button circle style="margin-right: 8px;">
                <el-icon>
                    <bell/>
                </el-icon>
            </el-button>
            <el-button circle>
                <el-icon>
                    <user/>
                </el-icon>
            </el-button>
        </div>
    </div>
</template>

<script setup>
</script>

<style scoped>
    .header-container {
        width: 100%;
        display: flex;
        align-items: center;
    }

    .right-tools {
        display: flex;
        align-items: center;
        margin-left: auto; /* 关键:将整个 div 推到最右侧 */
    }
</style>


admin/base.vue

<!--
 * @Author Malred · Wang
 * @Date 2025-06-17 11:26:20
 * @Description
 * @Path src/admin/base.vue
 -->
<template>
    <el-container style="height: 100vh; width: 100vw;">
        <!-- 左侧边栏 -->
        <el-aside width="200px" style="background-color: #409EFF; height: 100vh;">
            <layout-aside/>
        </el-aside>

        <!-- 主体内容 -->
        <el-container style="flex: 1; min-width: 0;">
            <!-- 头部 -->
            <el-header style="background-color: #4fa3fa; height: 60px; display: flex; align-items: center;">
                <layout-header/>
            </el-header>

            <!-- 内容区域 -->
            <el-main style="margin: 0; padding: 0; height: calc(100vh - 60px);">
                <router-view/>
            </el-main>

            <!-- 底部 -->
            <el-footer style="display: flex; justify-content: center; align-items: center;">
                版权所有 @Malred
            </el-footer>
        </el-container>
    </el-container>
</template>


<script setup>
</script>

<style scoped>
    .el-main {
        --el-main-padding: 0px;
    }
</style>


utils/menu.js

/*
 * @Author Malred · Wang
 * @Date 2025-06-17 12:52:12
 * @Description 
 * @Path src/utils/menu.js
 */

export function buildMenuTree(routes) {
    console.log('build menu routes', routes)
    const tree = [];
    const map = {};

    // 先全部映射到 map
    routes.forEach(route => {
        map[route.path] = {...route, children: []};
    });

    // 构建树结构
    routes.forEach(route => {
        const path = route.path;
        const parentPath = path.substring(0, path.lastIndexOf('/'));

        if (parentPath && map[parentPath]) {
            map[parentPath].children.push(map[path]);
        } else {
            tree.push(map[path]);
        }
    });

    return tree;
}

base组件

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


<!-- base-operation-item.vue -->
<template>
    <el-form-item :label="label">
        <el-input
                :modelValue="value"
                @update:modelValue="$emit('update:value', $event)"
        />
    </el-form-item>
</template>

<script setup>
    import {defineProps, defineEmits} from 'vue';

    defineProps({
        label: String,
        value: [String, Number]
    });

    const emit = defineEmits(['update:value']);
</script>
<!--
 * @Author Malred
 * @Date 2025-06-02 05:13:16
 * @Description 弹窗组件
 * @Path src/components/base/operation.vue
 -->
<!-- Operation.vue -->
<template>
    <el-dialog v-model="visible" :title="title" width="50%">
        <el-form :model="formData" label-width="120px">
            <slot></slot>
        </el-form>
        <template #footer>
            <el-button @click="visible = false">取消</el-button>
            <el-button type="primary" @click="submit">提交</el-button>
        </template>
    </el-dialog>
</template>

<script setup>
    import {ref} from 'vue';

    // 定义 emits
    const emit = defineEmits(['submit']);

    // 控制弹窗显示
    const visible = ref(false);

    // 显示方法
    const show = () => {
        visible.value = true;
    };

    // 提交方法
    const submit = () => {
        emit('submit'); // 触发 submit 事件
        visible.value = false;
    };

    // 暴露方法给父组件调用
    defineExpose({show});

    import {defineProps} from 'vue';

    defineProps({
        title: String,
        formData: {
            type: Object,
            default: () => ({})
        }
    });
</script>

<!--
 * @Author Malred
 * @Date 2025-06-02 06:18:53
 * @Description 日期选择搜索组件
 * @Path src/components/base/table/search/date.vue
 -->
<!-- BaseSearchDate.vue -->
<template>
    <div class="search-item">
        <label v-if="label">{{ label }}</label>
        <el-date-picker
                v-model="value"
                type="date"
                placeholder="选择日期"
                value-format="YYYY-MM-DD"
        />
    </div>
</template>

<script setup>
    import {defineProps, defineModel} from 'vue';

    defineProps({
        label: String
    });

    const value = defineModel();
</script>


<style scoped>
    .search-item {
        display: flex;
        flex-direction: row;
        align-items: center;
        /*flex-wrap: wrap;*/
        gap: 8px;
    }
</style>

<!--
 * @Author Malred
 * @Date 2025-06-02 05:12:31
 * @Description 普通的输入搜索框
 * @Path src/components/base/table/search/item.vue
 -->
<!-- SearchItem.vue -->
<template>
    <div class="search-item">
        <label v-if="label">{{ label }}</label>
        <el-input style="width: 120px;" v-model="value" placeholder="请输入"/>
    </div>
</template>

<script setup>
    import {defineProps, defineModel} from 'vue';

    const props = defineProps({
        label: String
    });

    const value = defineModel();
</script>

<style scoped>
    .search-item {
        display: flex;
        flex-direction: row;
        align-items: center;
        /*flex-wrap: wrap;*/
        gap: 8px;
    }
</style>

<!--
 * @Author Malred
 * @Date 2025-06-02 06:18:42
 * @Description 下拉选择搜索框
 * @Path src/components/base/table/search/select.vue
 -->
<!-- BaseSearchSelect.vue -->
<template>
    <div class="search-item">
        <label v-if="label">{{ label }}</label>
        <el-select v-model="value" placeholder="请选择">
            <el-option
                    v-for="item in options"
                    :key="item.value"
                    :label="item.label"
                    :value="item.value"
            />
        </el-select>
    </div>
</template>

<script setup>
    import {defineProps, defineModel} from 'vue';

    defineProps({
        label: String,
        options: {
            type: Array,
            required: true,
            default: () => []
        }
    });

    const value = defineModel();
</script>
<style scoped>
    .search-item {
        display: flex;
        flex-direction: row;
        align-items: center;
        /*flex-wrap: wrap;*/

        width: 120px;

        label {
            width: 56px;
        }
    }
</style>
<!--
 * @Author Malred
 * @Date 2025-06-02 05:10:43
 * @Description 表头组件
 * @Path src/components/base/table/header.vue
 -->
<!-- base-table-header.vue -->
<template>
    <div class="table-header">
        <!-- 插槽可以自由插入任意结构,比如 el-form -->
        <div class="search-container">
            <slot name="search"></slot>
        </div>
        <div class="actions">
            <slot name="action">
                <el-button type="primary" @click="$emit('add')">新增</el-button>
                <el-button
                        type="danger"
                        @click="onDeleteSelected"
                        :disabled="deleteDisabled"
                >
                    删除选中
                </el-button>
            </slot>
        </div>
    </div>
</template>

<script setup>
    defineProps({
        deleteDisabled: Boolean,
        onDeleteSelected: {
            type: Function,
            default: null
        }
    });
    defineEmits(['add']);
</script>

<style scoped>
    .table-header {
        display: flex;
        justify-content: space-between;
        align-items: center; /* 垂直居中 */
        margin-bottom: 12px;
        margin-top: 8px;
        flex-wrap: wrap;
        gap: 12px;
    }

    .search-container {
        display: flex;
        align-items: center;
        flex-grow: 1;
        min-width: 200px;
        gap: 8px;
    }

    .actions {
        display: flex;
        gap: 8px;
    }
</style>


<!--
 * @Author Malred
 * @Date 2025-06-02 05:11:31
 * @Description 表格body组件
 * @Path src/components/base/table/body.vue
 -->
<template>
    <el-table :data="data" border style="width: 100%" @selection-change="handleSelectionChange">
        <!-- 多选列 -->
        <el-table-column type="selection" width="55"></el-table-column>

        <!-- 动态列 -->
        <el-table-column
                v-for="(col, index) in columns"
                :key="index"
                :prop="col.prop"
                :label="col.label"
                :width="col.width"
        >
            <template #default="scope">
                <slot :name="'col-' + col.prop" :row="scope.row">
                    {{ scope.row[col.prop] }}
                </slot>
            </template>
        </el-table-column>

        <!-- 操作列 -->
        <el-table-column label="操作" width="150">
            <template #default="scope">
                <slot name="operation" :row="scope.row">
                    <el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
                    <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
                </slot>
            </template>
        </el-table-column>
    </el-table>
</template>

<script setup>
    import {defineProps, defineEmits, ref} from 'vue';

    const props = defineProps({
        data: {
            type: Array,
            required: true
        },
        columns: {
            type: Array,
            required: true
        },
        onDelete: { // 新增 prop,接收删除方法
            type: Function,
            default: null
        }
    });

    const emit = defineEmits(['edit', 'delete', 'selection-change']);

    const selectedRows = ref([]);

    const handleSelectionChange = (rows) => {
        selectedRows.value = rows;
        emit('selection-change', rows);
    };

    const handleEdit = (row) => {
        emit('edit', row);
    };

    const handleDelete = (row) => {
        if (props.onDelete) {
            props.onDelete([row]);
        } else {
            emit('delete', [row]);
        }
    };
</script>


测试: admin/test/page.vue

<!--
 * @Author Malred · Wang
 * @Date 2025-06-17 13:27:45
 * @Description 测试表格组件
 * @Path src/admin/test/page.vue
 -->
<template>
    <div class="container">
        <div class="container-wrapper space-y-4">
            <base-table-header
                    :delete-disabled="selectedRows.length === 0"
                    @add="openAddDialog"
                    :onDeleteSelected="handleDeleteSelected"
            >
                <template #search>
                    <!-- 文本输入 -->
                    <base-table-search-item label="用户名" v-model="searchForm.username"/>

                    <!-- 邮箱输入 -->
                    <base-table-search-item label="邮箱" v-model="searchForm.email"/>

                    <!-- ID 输入 -->
                    <base-table-search-item label="ID" v-model="searchForm.id"/>

                    <!-- 状态选择 -->
                    <base-table-search-select
                            label="状态"
                            v-model="searchForm.status"
                            :options="[
                { label: '启用', value: 'active' },
                { label: '禁用', value: 'inactive' }
              ]"
                    />

                    <!-- 创建时间 -->
                    <base-table-search-date label="创建时间" v-model="searchForm.createTime"/>

                    <!-- 清空按钮 -->
                    <el-button type="info" size="small" @click="resetSearchForm">重置</el-button>
                </template>
            </base-table-header>

            <base-table-body
                    :data="tableData"
                    :columns="columns"
                    @selection-change="handleSelectionChange"
                    @edit="openEditDialog"
                    @delete="handleDelete"
                    :onDelete="handleDelete"
            >
                <!-- 自定义操作列 -->
                <template #operation="props">
                    <el-button link type="primary" size="small" @click="openEditDialog(props.row)">编辑</el-button>
                    <el-button link type="danger" size="small" @click="handleDelete([props.row])">删除</el-button>
                </template>
            </base-table-body>

            <!-- 弹窗部分 -->
            <base-operation ref="operationDialog" title="用户信息" @submit="submitForm">
                <base-operation-item
                        label="用户名"
                        :value="formData.username"
                        @update:value="val => formData.username = val"
                />
                <base-operation-item
                        label="邮箱"
                        :value="formData.email"
                        @update:value="val => formData.email = val"
                />
            </base-operation>

        </div>
    </div>
</template>

<script setup>
    import {ref, watch} from 'vue';

    // 数据源
    const originalData = [
        {id: 1, username: 'admin', email: 'admin@example.com', status: 'active', createTime: '2025-06-01'},
        {id: 2, username: 'user1', email: 'user1@example.com', status: 'inactive', createTime: '2025-06-02'},
        {id: 3, username: 'test', email: 'test@example.com', status: 'active', createTime: '2025-06-03'}
    ];
    const tableData = ref([...originalData]);

    // 表格列配置
    const columns = [
        {prop: 'id', label: 'ID'},
        {prop: 'username', label: '用户名'},
        {prop: 'email', label: '邮箱'},
        {prop: 'status', label: '状态'},
        {prop: 'createTime', label: '创建时间'},
    ];

    // 搜索表单
    const searchForm = ref({
        username: '',
        email: '',
        id: '',
        status: '',
        createTime: ''
    });

    // 重置搜索表单
    const resetSearchForm = () => {
        searchForm.value = {
            username: '',
            email: '',
            id: '',
            status: '',
            createTime: ''
        };
    };


    // 新增/编辑表单数据
    const formData = ref({
        username: '',
        email: '',
    });

    const operationDialog = ref(null);

    // 打开新增弹窗
    const openAddDialog = () => {
        formData.value = {username: '', email: ''};
        operationDialog.value.show();
    };

    // 打开编辑弹窗
    const openEditDialog = (row) => {
        formData.value = {...row};
        operationDialog.value.show();
    };

    // 提交新增/编辑
    const submitForm = () => {
        if (!formData.value.id) {
            // 新增
            const newRow = {
                id: Date.now(),
                username: formData.value.username,
                email: formData.value.email
            };
            tableData.value.unshift(newRow);
        } else {
            // 编辑
            const index = tableData.value.findIndex(item => item.id === formData.value.id);
            if (index > -1) {
                tableData.value.splice(index, 1, {...formData.value});
            }
        }
    };

    // 多选行
    const selectedRows = ref([]);
    const handleSelectionChange = (rows) => {
        selectedRows.value = rows;
    };

    // 删除
    const handleDelete = (rows) => {
        tableData.value = tableData.value.filter(row =>
                !rows.some(r => r.id === row.id)
        );
    };

    // 批量删除
    const handleDeleteSelected = () => {
        if (selectedRows.value.length === 0) return;
        handleDelete(selectedRows.value);
    };

    function applySearch(data, search) {
        const {username, email, id, status, createTime} = search;

        return data.filter(item => {
            return (
                    (!username || item.username.toLowerCase().includes(username.toLowerCase())) &&
                    (!email || item.email.toLowerCase().includes(email.toLowerCase())) &&
                    (!id || item.id.toString().includes(id.toString())) &&
                    (!status || item.status === status) &&
                    (!createTime || item.createTime === createTime)
            );
        });
    }

    // 监听搜索变化
    watch(
            () => searchForm.value,
            (newVal) => {
                tableData.value = applySearch(originalData, newVal);
            },
            {deep: true}
    );


</script>

<style scoped>
    .search-form {
        display: flex;
        align-items: center; /* 关键:垂直居中 */
        gap: 12px; /* 可选:增加项之间间距 */
    }
</style>

i18n切换按钮

i18n是国际化的常用库

# v9
pnpm i vue-i18n

src/includes/i18n.js

import {createI18n} from "vue-i18n";

export const messages = {};

// 扫描locales目录, 注册i18n国际化语言包
const modules = import.meta.glob('../locales/*.json'); // 只读取当前目录下的 json 文件,不递归子目录

for (const path in modules) {
    const key = path.match(/\.\.\/locales\/([^.]+)\.json$/)?.[1];
    if (key) {
        const module = await modules[path]();
        messages[key] = module.default; // 注意这里加了 .default
    }
    console.log(key) // en
    console.log(path)
}

console.log('messages', messages)

export default createI18n({
    legacy: false, // Vue 3 Composition API 模式
    locale: 'cn', // 默认语言
    fallbackLocale: 'cn',
    messages,
});

main.js

import i18n from "@/includes/i18n.js";

app.use(i18n)

src/locales/cn.json

{
  "error": {
    "nofound": "页面跑丢了~!"
  },
  "greet": "你好",
  "info": {
    "translate": {
      "btn": {
        "cn": "中文",
        "en": "英文"
      }
    }
  }
}

src/locales/en.json

{
  "error": {
    "nofound": "404 NOT FOUND!"
  },
  "greet": "hello",
  "info": {
    "translate": {
      "btn": {
        "cn": "chinese",
        "en": "english"
      }
    }
  }
}

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


<template>
    <el-dropdown trigger="click" @command="handleSelect">
        <el-button circle style="background-color: transparent;">
            <img src="../assets/icons/components/translation.svg" alt="Language" style="width: 20px; height: 20px;"/>
        </el-button>

        <template #dropdown>
            <el-dropdown-menu>
                <el-dropdown-item v-for="option in options" :key="option.key" :command="option.key">
                    {{ option.label }}
                </el-dropdown-item>
            </el-dropdown-menu>
        </template>
    </el-dropdown>
</template>

<script setup>
    import {computed} from 'vue'
    import {useI18n} from 'vue-i18n'

    const {t, locale} = useI18n()

    // 动态生成语言选项
    const options = computed(() => [
        {
            label: t('info.translate.btn.en'),
            key: 'en'
        },
        {
            label: t('info.translate.btn.cn'),
            key: 'cn'
        }
    ])

    // 切换语言
    const handleSelect = (key) => {
        locale.value = String(key)
    }
</script>

<style scoped>
    /* 可选:调整按钮样式 */
    .el-button {
        border: none;
        padding: 0;
        width: 36px;
        height: 36px;
    }
</style>

公共方法 utils

在utils文件夹下存放功能函数, 我们通过扫描该目录下的js文件, 然后挂载到vue实例上来全局使用

// index.js
const modules = import.meta.glob('./*.js'); // 注意:只读取当前目录的js文件,不递归

const utils = {};

for (const path in modules) {
    const moduleName = path.replace('./', '').replace('.js', '');
    const module = await modules[path](); // 获取对应路径的模块
    utils[moduleName] = module.default; // 提取默认导出
}

export default {
    install: (app) => {
        app.config.globalProperties.$utils = utils;
    },
};

获取vue3实例的方法

// vue-instance.js
import {getCurrentInstance} from "vue"

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

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

app.use(utils)

测试

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

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

状态管理 store

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

# v3
pnpm i pinia
// index.js
import {createPinia} from 'pinia'
import {useUserStore} from './user-store.js'

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

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

// user-store.js
import {defineStore, acceptHMRUpdate} from 'pinia'

export const useUserStore = defineStore('user', {
    state: () => ({
        name: '张三',
        age: 25
    }),
    // getters: {},
    actions: {
        setName(newName) {
            this.name = newName
        }
    }
})

// 支持pinia热更新, 不需要刷新浏览器
if (import.meta.hot) {
    import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
}
// main.js
import store from "@/store/index.js";

app.use(store) 

测试


<template>
    <div>
        <div @click="userStore.setName('malred')">{{ userStore.name }}</div>
    </div>
</template>
<script setup>
    import {useUserStore} from "../store/index.js";

    const userStore = useUserStore()
</script>

网络请求 api

使用axios进行网络请求, 一般情况下, 前后端是分人分组开发的, 前端请求接口地址是后端提供的
如果你此时后端还没有接口, 就需要自己用假数据或者用json-server来模拟测试

# v1
pnpm i axios

封装

// request.js
import axios from 'axios'
import settings from "../settings.js";

const request = axios.create({
    baseURL: settings.BaseURL, // 设置基础URL
    timeout: settings.timeout, // 请求超时时间
})

// 请求拦截器
request.interceptors.request.use(
    (config) => {
        // 在发送请求之前做些什么,例如添加 token
        const token = localStorage.getItem('token')
        if (token) {
            config.headers['Authorization'] = `Bearer ${token}`
        }
        return config
    },
    (error) => {
        // 对请求错误做些什么
        return Promise.reject(error)
    }
)

// 响应拦截器
request.interceptors.response.use(
    (response) => {
        // 对响应数据做处理
        return response.data
    },
    (error) => {
        // 对响应错误做处理
        return Promise.reject(error)
    }
)

export default request

扫描并注册到vue实例

// index.js
const modules = import.meta.glob('./**/index.js') // 只扫描 index.js 文件
const requests = {}

for (const path in modules) {
    if (path === './index.js') continue // 跳过自身

    const modulePath = path
        .replace('./', '')
        .replace('/index.js', '') // 去除前缀和后缀
    const segments = modulePath.split('/') // 分割路径层级

    // 安全获取模块名:如果是单层目录(如 admin/index),则直接取第一个段
    const moduleName = segments[segments.length - 1]
    console.log('api', moduleName)
    console.log('api', segments)

    let currentLevel = requests

    // 构建嵌套结构
    // for (let i = 0; i < segments.length - 1; i++) {
    for (let i = 0; i < segments.length; i++) {
        const segment = segments[i]
        currentLevel[segment] = currentLevel[segment] || {}
        currentLevel = currentLevel[segment]
    }

    // 加载模块并挂载到对应位置
    // 要加await, 否则为这些函数为空
    await modules[path]().then(module => {
        console.log('currentLevel', currentLevel)
        console.log('moduleName', moduleName)

        // currentLevel[moduleName] = module.default
        // 然后直接赋值给 currentLevel(不再使用 moduleName)
        Object.assign(currentLevel, module.default)
        // currentLevel = module.default
    })
}

export default {
    install: (app) => {
        // app.config.globalProperties.$requests = requests
        app.config.globalProperties.$api = requests
    },
}

添加到main.js

// main.js
import api from '@/api/index.js'

app.use(api)

测试

// api/admin/index.js
import request from "../request.js";

export default {
    // 查询全部
    queryAdmin(body) {
        return request({url: '/api/admin', method: 'get', data: '', params: body})
    },
    // 根据id查询单个
    queryAdminDetail(id) {
        return request({url: '/api/admin' + id, method: 'get', data: '', params: ''})
    },
    // 添加
    addAdmin(body) {
        return request({url: '/api/admin', method: 'post', data: body, params: ''})
    },
    // 修改
    editAdmin(body, id) {
        return request({url: '/api/admin' + id, method: 'put', data: body, params: ''})
    },
    // 删除
    deleteAdmin(ids) {
        return request({url: '/api/admin' + ids.join(','), method: 'delete', data: '', params: ''})
    },
}

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

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

下一步

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

社群

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

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐