Ezekielx
Ezekielx
发布于 2025-09-30 / 57 阅读
0
2

前端 Vue3 + Element Plus 后端 Node.js + MongoDB QQ 群 AI 聊天机器人

一、前言

博主大三上软件工程课要交一个项目,就想着在平时聊天的 QQ 群里面搞一个接入 AI 的 QQ 机器人。

这个想法确实很早就有了,也已经部署过一个聊天的猫娘机器人,不过用的是一个叫 Kirara AI 的现成开源项目,挺好用的,就想着自己写一个当作业交上去。

上个学期学数据库应用的时候也是要交一个前端 + 后端的项目,当时教的是 Swing + JDBC 这种纯 Java 的方式写前后端。但说实话,Swing 东西又老又丑,我记得期末的时候就没几个用 Swing 做项目(老师也没限制用什么,只有你有东西出来🤔)的,大多用的都是 Vue + SpringBoot。我不会 SpringBoot,就直接用的 node.js,毕竟做的就一个简单的学生成绩管理系统。

前端框架我们学校是教了一个 Vue 的,但后端没有教过,这导致这种项目课大多都是边学边做。感觉现在这些框架用的话已经用语法糖包得很简单了,就是用完了完全没印象,大多数时候就是对着官网的例子抄抄,再改点自己要用的东西😶‍🌫️。这次做项目的时候发现好多东西都忘了怎么用的了,翻之前的项目结果连启动后端服务器的命令都忘了🐱。想着不如就把这次做的过程记录下来🥰。

尽量把项目做得严谨一点,不严谨的地方也不要压力🕊️。

顺便放一个代码仓库地址:Ezekielx-Evans/little-mouse-ai

二、创建项目

注意:写完前端后我的项目路径改动过一次,文章描述是正常的,但前端部分一些显示有路径的图片可能与创建项目部分描述的不一样,不过并不影响阅读🐦‍⬛。

1、创建整体项目

IDE 我用的是 JetBrains 家的 WebStorm,挺好用的(用 IDEA 也可以,官网也说了可以通过插件实现 WebStorm 的所有功能,但推荐 Web 项目使用专业的 WebStorm),个人还是免费的🦆。

新建一个项目,选择空项目,我的项目名称叫 little-mouse-ai。

CUKd0Ik7-1.png

使用 git init 创建 git 仓库(可选,我感觉用一下 git 方便出问题后回退排查🤔)。

CUKd0Ik7-2.png

2、创建前端项目

在刚刚的项目目录里面再创建一个 Vue.js 的前端项目,项目名称为整个项目名称后面加上 -frontend 的后缀。

CUKd0Ik7-3.png

npm install 安装依赖。

npm run dev,打开本地连接,查看是否正常启动。

CUKd0Ik7-4.png

删除模板文件和 App.vue、main.js 中的模板代码。

CUKd0Ik7-5.png

App.vue

<script setup>

</script>

<template>

</template>

<style scoped>

</style>

main.js

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.mount('#app')

3、创建后端项目

回到项目目录,创建一个 Node.js 的后端项目,项目名称为整个项目名称后面加上 -backend 的后缀。

CUKd0Ik7-6.png

三、前端

打开前端项目。

1、引入 Element Plus

一个基于 Vue 3,面向设计师和开发者的组件库。挺好用的,按 Element Plus 官网的教程引入就行了🐱(引入后记得再次使用 npm install 命令安装)。

CUKd0Ik7-7.png

CUKd0Ik7-8.png

Element Plus 的所有组件可在 Overview 组件总览 | Element Plus 查看。

2、添加一级路由

创建路由配置文件

准备做两个页面,一个登录页面和一个功能页面。一级路由加这两个就差不多了。

src 目录下创建一个 router 目录,在此目录下创建 index.js 文件编写路由信息。

这是当前最新的 Vue Router 的官方文档:Vue Router | Vue.js 的官方路由

使用 npm 在控制台安装 vue-router。

CUKd0Ik7-9.png

复制官方文档的例子到 index.js

,使用 export default router 导出,等将一级路由的组件创建完后再修改配置文件🥳。

CUKd0Ik7-10.png

CUKd0Ik7-11.png

注册路由插件

创建了路由实例后还需要将其注册为插件。

已经参照官方文档,由于注册的插件会有很多,最好分开注册。

CUKd0Ik7-12.png

CUKd0Ik7-13.png

创建页面组件

src 目录下创建一个 view 目录,用来存放页面组件。

view 文件夹下创建 layout 目录来存放功能页面的组件,login 目录存放登录页面组件。两个目录下都创建 index.vue 默认组件。

CUKd0Ik7-14.png

编辑路由配置文件

vite.config.js 配置文件中添加 extensions: ['.js', '.vue'],指定在导入模块时可以省略的文件扩展名。

导入组件路径,@ 代表 src 目录,由于前面在 vite.config.js 配置了可以省略的文件扩展名,所以路径写到组件的存放目录后就能自动识别了😎(如果不想改 vite.config.js,在路径后把 index.vue 加上也是可以的)。

CUKd0Ik7-15.png

CUKd0Ik7-16.png

设置路由对应页面渲染位置

已经创建了两个页面和对应的路由,现在要找地方将页面展示出来。

<RouterView> 就是展示用的组件,这个组件放在哪里,哪里就会替换渲染成对应路由的组件。

我一般会将页面组件直接放到 App.vue 这个全局组件里面。在 App.vue 中添加 <RouterView>

CUKd0Ik7-17.png

3、整体页面布局

页面的大体布局,Element Plus 里面有例子,这里我用的侧边栏加主体部分的布局😎。

大概就是这个效果。

CUKd0Ik7-18.png

参照 Element Plus 的 Container 容器布局在 src/views/layout/index.vue 中大体的模板写好。运行项目,查看效果。

CUKd0Ik7-19.png

CUKd0Ik7-20.png

4、编写侧边栏

CUKd0Ik7-21.png

分顶部信息和菜单两个部分写。

顶部信息

侧边栏顶部展示信息,我一般喜欢放个图标然后后面接项目名字。最近玩三角洲比较上瘾,准备搞一个鼠鼠机器人,这个项目就叫 Mouse AI 吧🐭。

先把项目 Logo 放到 src/assets/images 目录下。

<el-aside> 侧边栏高度设为 100vh(占满整个浏览器窗口高度),添加边框。

<el-header> 容器修改为 flex,里面的元素居中排列。

<el-header> 中放 Logo 和 Title,调整好大小间距。

<script setup>
</script>

<template>
    <div class="common-layout">
        <el-container>
            <!-- 侧边栏 -->
            <el-aside class="sidebar" width="200px">
                <el-container>

                    <!-- Logo + Title -->
                    <el-header class="logo-title">
                        <el-image class="logo" src="src/assets/images/little-mouse.png"/>
                        <span class="title">Mouse AI</span>
                    </el-header>

                    <!-- Menu -->
                    <el-main>
                        Menu
                    </el-main>

                </el-container>
            </el-aside>
            <el-container>

                <!-- 主页面 -->
                <el-main>
                    Main
                </el-main>

            </el-container>
        </el-container>
    </div>
</template>

<style scoped>

.sidebar {
    height: 100vh;
    border-right: 1px solid var(--el-border-color);
}

.logo-title {
    display: flex;
    align-items: center;
    justify-content: start;
    height: 64px;
    border-bottom: 1px solid var(--el-border-color);
}

.logo {
    width: 36px;
    height: 36px;
    margin-right: 8px;
}

.title {
    font-size: 18px;
    font-weight: bold;
    color: #333;
}

</style>

CUKd0Ik7-22.png

菜单

准备做下面五个页面的菜单:

  • 首页
  • 请求记录
  • 机器人管理
  • 大模型管理
  • 流程管理
  • 设置

每个菜单选项也是 Icon + Title 的形式。对于 Icon 图标,可以选择用 Element Plus 提供的图标库,但缺点是种类有限(比如我想要一个 QQ 的图标它就没有😓)。这里推荐一个网站:iconfont-阿里巴巴矢量图标库,真的非常非常好用😍。

直接搜索你想要的图标,有非常多的格式可以选择,我这里就直接复制 SVG 代码插入了。

使用 <el-menu> 创建一个垂直菜单,设置菜单样式。

如果是从 iconfont 复制的 SVG 代码会自带 icon 类和 fill 属性(图标颜色),如果想让图标颜色和文字颜色同时改变的话,需要删除 <svg> 标签中的 fill 属性,再在 icon 类选择器中添加 fill: currentColor; 属性。

下面是 src/views/layout/index.vue

<script setup>
</script>

<template>
    <div class="common-layout">
        <el-container>

            <!-- 侧边栏 -->
            <el-aside class="sidebar" width="200px">
                <el-container>

                    <!-- Logo + Title -->
                    <el-header class="logo-title">
                        <el-image class="logo" src="src/assets/images/little-mouse.png"/>
                        <span class="title">Mouse AI</span>
                    </el-header>

                    <!-- Menu -->
                    <el-main class="menu-area">
                        <!-- default-active:默认激活菜单的 index -->
                        <el-menu class="custom-menu" default-active="1">
                            <!-- 首页 -->
                            <el-menu-item index="/">
                                <svg class="icon" height="200" viewBox="0 0 1024 1024" width="200"
                                     xmlns="http://www.w3.org/2000/svg">
                                    <path
                                        d="M620 752h64a4 4 0 0 0 4-4V556a4 4 0 0 0-4-4h-64a4 4 0 0 0-4 4v192a4 4 0 0 0 4 4z m321.9-258.2L832 383.8V196a4 4 0 0 0-4-4H724a4 4 0 0 0-4 4v75.8L540.3 92.1a40.1 40.1 0 0 0-56.6 0l-22.6 22.7-379 379a3.9 3.9 0 0 0 0 5.6l45.2 45.3a4.2 4.2 0 0 0 5.7 0l59-59v369.9a40 40 0 0 0 40 40h560a40 40 0 0 0 40-40V485.7l59 59a4.2 4.2 0 0 0 5.7 0l45.2-45.3a3.9 3.9 0 0 0 0-5.6zM760 823.6H264V413.7l248-248 248 248z">
                                    </path>
                                </svg>
                                首页
                            </el-menu-item>

                            <!-- 请求记录 -->
                            <el-menu-item index="/requests">
                                <svg class="icon" height="200" viewBox="0 0 1024 1024" width="200"
                                     xmlns="http://www.w3.org/2000/svg">
                                    <path
                                        d="M511.7 958.9C264.9 958.9 64 758.1 64 511.3S264.9 63.7 511.7 63.7s447.6 200.8 447.6 447.6c0 99.7-32.1 194-92.7 272.8-10.1 13.1-28.8 15.5-41.9 5.4-13.1-10.1-15.5-28.8-5.4-41.9 52.5-68.3 80.3-150 80.3-236.4 0-213.9-174-388-388-388s-388 174-388 388c0 213.9 174 388 388 388 59.5 0 116.6-13.1 169.7-39 14.8-7.3 32.7-1.1 39.9 13.8 7.2 14.8 1.1 32.7-13.8 39.9-61.2 29.9-127.1 45-195.7 45z">
                                    </path>
                                    <path
                                        d="M466.4 530h247.7c13.9 0 25.4 11.4 25.4 25.4v9c0 13.9-11.4 25.4-25.4 25.4H466.4c-13.9 0-25.4-11.4-25.4-25.4v-9c0.1-13.9 11.5-25.4 25.4-25.4z">
                                    </path>
                                    <path
                                        d="M441.1 564.2v-249c0-14 11.5-25.5 25.5-25.5h8.7c14 0 25.5 11.5 25.5 25.5v249c0 14-11.5 25.5-25.5 25.5h-8.7c-14.1 0-25.5-11.4-25.5-25.5zM837.8 667.9l30.6 84.1c5.6 15.4-2.4 32.6-17.8 38.3-15.4 5.6-32.6-2.4-38.3-17.8l-30.6-84.1c-5.6-15.4 2.4-32.6 17.8-38.3 15.4-5.6 32.7 2.4 38.3 17.8z">
                                    </path>
                                </svg>
                                请求记录
                            </el-menu-item>

                            <!-- 机器人管理 -->
                            <el-menu-item index="/bots">
                                <svg class="icon" height="200" viewBox="0 0 1024 1024" width="200"
                                     xmlns="http://www.w3.org/2000/svg">
                                    <path
                                        d="M550.744931 834.806034 550.744931 834.806034c-11.183713 0-16.754592 0-22.327518 0-5.53404 0-11.144828 0-16.718777 0l-5.572926 0c-5.571902 0-16.717753 0-22.289656 5.53404-22.290679 22.326495-61.298088 33.475416-111.453395 33.475416-33.434483 0-61.298088-5.610788-89.163739-16.7198-22.289656-11.143805-33.435507-22.326495-33.435507-39.009456 0-16.717753 11.143805-27.862581 39.009456-39.045271 16.717753-5.535063 22.289656-11.106965 22.289656-22.326495 0-11.106965 0-22.25384-5.572926-27.863604-11.144828-11.034311-16.718777-27.788903-27.863604-38.896892-5.572926-11.144828-16.71673-16.755616-27.861558-16.755616l0 0c-11.144828 0-16.717753 5.608741-27.863604 11.220552-11.143805 11.106965-16.717753 16.644075-22.290679 16.644075l0 0c-5.572926 0-11.106965-16.644075-11.106965-44.505633 0-27.863604 5.535063-61.336974 11.070126-94.810343 11.145851-33.398668 27.862581-61.261249 55.725162-89.124853 5.574972-5.535063 11.146874-16.717753 11.146874-27.863604 0-5.53404 0-5.53404 0-11.106965 0-16.755616 5.571902-33.435507 16.71673-50.154283 5.573949-5.572926 5.573949-11.143805 5.573949-16.717753l0-5.573949c0.035816-66.908876 22.288632-122.635061 72.480778-172.789345 44.582381-50.191122 100.30652-72.444962 167.180604-72.444962 66.872037 0 122.600269 22.290679 172.751482 72.444962 44.618197 50.154283 72.480778 105.880469 72.480778 172.715667 0 0 0 0 0 5.572926 0 5.573949 0 11.144828 5.536087 16.717753 11.141758 16.718777 16.717753 33.398668 16.717753 50.154283 0 5.572926 0 5.572926 0 11.106965 0 11.144828 0 22.326495 11.143805 27.862581 27.863604 27.862581 44.581358 55.726186 55.726186 89.124853 11.144828 33.474392 16.717753 66.944691 11.144828 94.810343 0 27.862581-5.573949 38.97057-16.681938 44.50768-5.573949 0-11.183713-5.535063-22.328541-16.645099-5.533017-5.572926-16.718777-11.219529-27.861558-11.219529l-5.536087 0c-11.184737 0-22.325471 5.606695-27.861558 16.753569-5.573949 16.71673-16.754592 27.863604-27.863604 38.969547-5.572926 5.608741-11.18269 16.754592-5.572926 27.863604 0 11.220552 11.145851 16.753569 16.682961 22.326495 27.826765 11.219529 39.010479 22.364357 39.010479 39.08211 0 16.680914-11.183713 27.863604-33.43653 39.008432-22.289656 11.106965-55.724139 16.7198-89.164762 16.7198-50.15326 0-83.58979-11.184737-111.452371-33.474392 5.571902 0-5.572926-5.572926-11.106965-5.572926l0 0L550.744931 834.806034zM678.880263 940.724365c55.726186 0 100.30652-11.143805 133.74305-33.398668 33.435507-22.326495 55.724139-50.191122 55.724139-89.163739 0-22.291702-5.53404-39.046295-16.678868-55.726186 5.571902 0 16.678868 0 22.287609-5.608741 33.435507-11.106965 50.15633-38.97057 55.727209-83.591837 5.571902-38.9675 0-83.58979-16.681938-133.706211-11.18269-39.046295-33.435507-72.444962-61.297065-105.880469l0 0c0-27.862581-5.573949-50.154283-16.7198-78.015841 0-83.590813-27.861558-156.034752-89.124853-217.370703C684.450119 76.963882 612.004133 49.098231 528.418436 49.098231c-83.590813 0-156.034752 27.862581-217.334887 89.124853-55.764048 61.33595-89.163739 133.781936-89.163739 217.370703-11.143805 22.290679-16.717753 50.154283-16.717753 78.016865l0 5.571902c-33.434483 27.863604-50.154283 66.87306-61.298088 100.305497-16.718777 50.118468-22.290679 94.737688-16.718777 133.708258 5.573949 44.61922 27.863604 72.482825 55.726186 83.590813 5.572926 0 16.718777 5.608741 22.290679 5.608741-11.144828 16.681938-16.718777 33.43653-16.718777 55.726186 0 33.435507 16.718777 66.872037 55.727209 89.163739 33.435507 22.291702 78.015841 33.435507 128.169101 33.435507 55.725162 0 105.880469-11.142781 139.316999-39.007409l27.863604 0c39.045271 27.862581 83.590813 39.007409 139.316999 39.007409l0 0L678.880263 940.724365z">
                                    </path>
                                </svg>
                                机器人管理
                            </el-menu-item>

                            <!-- 大模型管理 -->
                            <el-menu-item index="/models">
                                <svg class="icon" height="200" viewBox="0 0 1024 1024" width="200"
                                     xmlns="http://www.w3.org/2000/svg">
                                    <path
                                        d="M386.95424 122.34752a187.20768 187.20768 0 0 1 187.00288 0l197.40672 113.80736a187.20768 187.20768 0 0 1 93.70112 162.18624v41.81504a38.52288 38.52288 0 1 1-77.04064 0v-41.81504c0-9.02656-1.12128-17.8944-3.23584-26.43968l-267.7504 154.58816v316.7488a110.19776 110.19776 0 0 0 18.44224-8.4736l61.72672-35.64032a38.53312 38.53312 0 0 1 52.62848 14.10048 38.53824 38.53824 0 0 1-14.1056 52.62848l-61.72672 35.63008a187.19232 187.19232 0 0 1-187.10016 0.05632l-197.35552-113.77152a187.21792 187.21792 0 0 1-93.70624-162.18624V398.34112a187.2128 187.2128 0 0 1 93.70624-162.18624l197.40672-113.80736z m-211.456 252.2112a110.1824 110.1824 0 0 0-2.6112 23.7824v227.24096a110.1824 110.1824 0 0 0 55.1424 95.44192l197.3504 113.77152c4.73088 2.72896 9.6256 5.06368 14.61248 7.02976v-314.55744l-264.4992-152.704z m359.97696-185.45664a110.16192 110.16192 0 0 0-110.03904 0L228.02944 302.90432c-3.67616 2.11456-7.18848 4.43904-10.5472 6.92736l260.352 150.3232 263.27552-151.99232a109.568 109.568 0 0 0-8.23296-5.26336l-197.4016-113.79712zM914.76992 749.74208c0 7.93088-2.10432 13.99808-6.31808 18.21184s-10.28608 6.31808-18.21696 6.31808h-0.7424c-7.93088 0-13.99808-2.10432-18.21184-6.31808s-6.31808-10.28096-6.31808-18.21184V523.3664c0-7.92576 2.10432-13.99808 6.31808-18.21184s10.28096-6.31808 18.21184-6.31808h0.7424c7.93088 0 14.0032 2.10432 18.21696 6.31808s6.31808 10.28608 6.31808 18.21184v226.37568z">
                                    </path>
                                    <path
                                        d="M827.39712 744.9088c2.72384 7.68 2.2272 14.49472-1.48992 20.44416s-9.66144 8.91904-17.8432 8.91904h-1.4848c-8.17664 0-14.7456-2.35008-19.70176-7.06048-4.70528-4.95616-8.67328-12.14464-11.89376-21.56032l-72.11008-198.49216h20.8128l-72.11008 198.49216c-3.47136 9.41568-7.55712 16.60416-12.26752 21.56032-4.45952 4.7104-10.9056 7.06048-19.328 7.06048h-1.48992c-7.92576 0-13.8752-2.9696-17.83808-8.91904-3.71712-5.94944-4.21376-12.76416-1.48992-20.44416l81.77664-224.512c5.4528-14.37696 16.10752-21.56032 31.96928-21.56032h0.7424c15.86176 0 26.51648 7.18336 31.96928 21.56032l81.77664 224.512z m-52.7872-89.58464v48.32256h-122.66496v-48.32256h122.66496z">
                                    </path>
                                </svg>
                                大模型管理
                            </el-menu-item>

                            <!-- 流程管理 -->
                            <el-menu-item index="/process">
                                <svg class="icon" height="200" viewBox="0 0 1024 1024" width="200"
                                     xmlns="http://www.w3.org/2000/svg">
                                    <path
                                        d="M914.944 636.416h-86.016v-133.12h-274.432V435.2h77.824c38.912 0 70.656-31.744 70.656-70.656v-162.816c0-38.912-31.744-70.656-70.656-70.656h-223.744c-38.912 0-70.656 31.744-70.656 70.656v162.816c0 38.912 31.744 70.656 70.656 70.656h81.408V503.296h-277.504v133.12h-79.872c-38.912 0-70.656 31.744-70.656 70.656V870.4c0 38.912 31.744 70.656 70.656 70.656h223.744c38.912 0 70.656-31.744 70.656-70.656V707.072c0-38.912-31.744-70.656-70.656-70.656H271.36V562.176h498.688v74.24H691.2c-38.912 0-70.656 31.744-70.656 70.656V870.4c0 38.912 31.744 70.656 70.656 70.656h223.744c38.912 0 70.656-31.744 70.656-70.656V707.072c0-38.912-31.744-70.656-70.656-70.656zM356.352 695.296c6.656 0 11.776 5.12 11.776 11.776V870.4c0 6.656-5.12 11.776-11.776 11.776h-223.744c-6.656 0-11.776-5.12-11.776-11.776V707.072c0-6.656 5.12-11.776 11.776-11.776h223.744z m52.736-318.976c-6.656 0-11.776-5.12-11.776-11.776v-162.816c0-6.656 5.12-11.776 11.776-11.776h223.744c6.656 0 11.776 5.12 11.776 11.776v162.816c0 6.656-5.12 11.776-11.776 11.776h-223.744zM926.72 870.4c0 6.656-5.12 11.776-11.776 11.776H691.2c-6.656 0-11.776-5.12-11.776-11.776V707.072c0-6.656 5.12-11.776 11.776-11.776h223.744c6.656 0 11.776 5.12 11.776 11.776V870.4z">
                                    </path>
                                </svg>
                                流程管理
                            </el-menu-item>

                            <!-- 设置 -->
                            <el-menu-item index="/settings">
                                <svg class="icon" height="200" viewBox="0 0 1024 1024" width="200"
                                     xmlns="http://www.w3.org/2000/svg">
                                    <path
                                        d="M439.816 101.851c-8.631-16.952-28.007-25.48-46.337-20.396-70.066 19.435-133.165 55.501-184.82 103.628-12.909 12.028-16.413 31.094-8.623 46.926 5.374 10.92 8.414 23.234 8.414 36.376 0 45.555-36.833 82.347-82.1 82.347-0.381 0-0.762-0.003-1.142-0.008-17.691-0.253-33.448 11.147-38.74 28.031C73.159 421.209 66 466.344 66 513.078c0 30.844 3.118 61.001 9.07 90.156 4.1 20.082 22.714 33.816 43.111 31.808a83.069 83.069 0 0 1 8.169-0.399c45.267 0 82.1 36.791 82.1 82.346 0 20.276-7.254 38.74-19.334 53.086-13.177 15.649-12.423 38.718 1.748 53.472 52.742 54.916 119.403 96.417 194.376 118.784 20.888 6.231 42.918-5.408 49.543-26.174 10.616-33.275 41.714-57.212 78.217-57.212s67.601 23.937 78.217 57.212c6.625 20.766 28.655 32.405 49.543 26.174 74.973-22.367 141.634-63.868 194.376-118.784 14.17-14.755 14.924-37.823 1.748-53.471-12.08-14.346-19.334-32.811-19.334-53.087 0-45.554 36.834-82.346 82.1-82.346 2.773 0 5.496 0.135 8.169 0.399 20.397 2.008 39.011-11.726 43.111-31.808 5.951-29.155 9.07-59.312 9.07-90.156 0-46.734-7.16-91.869-20.468-134.323-5.292-16.884-21.049-28.285-38.741-28.031-0.379 0.005-0.76 0.008-1.141 0.008-45.266 0-82.1-36.792-82.1-82.347 0-13.142 3.04-25.456 8.414-36.376 7.79-15.832 4.286-34.898-8.623-46.926-51.655-48.127-114.754-84.193-184.82-103.628-18.33-5.084-37.706 3.444-46.337 20.396-13.648 26.806-41.357 44.97-73.184 44.97-31.827 0-59.536-18.164-73.184-44.97zM288.45 268.385c0-14.471-1.9-28.535-5.47-41.936 31.114-25.118 66.377-45.232 104.576-59.156 29.686 36.285 74.82 59.528 125.444 59.528 50.624 0 95.758-23.243 125.444-59.528 38.199 13.924 73.462 34.038 104.576 59.156a162.748 162.748 0 0 0-5.47 41.936c0 79.513 57.113 145.772 132.604 159.667 6.434 27.261 9.846 55.723 9.846 85.026 0 14.581-0.845 28.951-2.485 43.065-79.109 10.814-139.965 78.769-139.965 160.846 0 26.162 6.201 50.922 17.202 72.84-30.829 27.076-66.197 49.043-104.786 64.612-28.717-45.337-79.271-75.496-136.966-75.496-57.695 0-108.249 30.159-136.966 75.496-38.589-15.569-73.957-37.536-104.787-64.612 11.002-21.918 17.203-46.678 17.203-72.84 0-82.077-60.856-150.032-139.965-160.846A373.007 373.007 0 0 1 146 513.078c0-29.304 3.411-57.765 9.845-85.026 75.492-13.894 132.605-80.154 132.605-159.667zM513 336c-97.202 0-176 78.798-176 176s78.798 176 176 176 176-78.798 176-176-78.798-176-176-176zM409 512c0-57.438 46.562-104 104-104s104 46.562 104 104-46.562 104-104 104-104-46.562-104-104z">
                                    </path>
                                </svg>
                                设置
                            </el-menu-item>

                        </el-menu>
                    </el-main>

                </el-container>
            </el-aside>

            <el-container>

                <!-- 主页面 -->
                <el-main>
                    Main
                </el-main>

            </el-container>
        </el-container>
    </div>
</template>

<style scoped>
.sidebar {
    height: 100vh;
    border-right: 1px solid var(--el-border-color);
}

.logo-title {
    display: flex;
    align-items: center;
    justify-content: start;
    height: 64px;
    border-bottom: 1px solid var(--el-border-color);
}

.logo {
    width: 36px;
    height: 36px;
    margin-right: 8px;
}

.title {
    font-size: 18px;
    font-weight: bold;
    color: #333;
}

.menu-area {
    padding: 0;
}

.custom-menu {
    border-right: none;
}

.custom-menu .el-menu-item {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 15px;
    height: 56px;
    border-radius: 8px;
    margin-top: 8px;
    margin-bottom: 8px;
}

.icon {
    width: 30px;
    height: 30px;
    fill: currentColor;
}
</style>

CUKd0Ik7-23.png

5、清除浏览器默认样式

这时打开浏览器查看做出来的效果会发现出现了滚动条,可高度明明设置的 100vh 按道理来说应该是刚刚好占满浏览器窗口的,为什么会溢出来一点呢?

CUKd0Ik7-24.png

因为浏览器会自动给网页添加一个默认的样式,比如 8px 的外边距,这将会导致占满浏览器窗口后会溢出一部分。

解决方法就是在全局组件 App.vue 中添加以下样式代码:

<style>
html, body, #app {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
}
</style>

CUKd0Ik7-25.png

6、添加二级路由

侧边栏的菜单已经前面写好了,但还要实现点击不同菜单切换到不同的页面,这就需要给 Layout 组件设置子路由。

创建页面组件

由于我们的二级路由是 Layout 组件的子路由,在 src/views/layout 目录下创建和菜单对应的 Home.vueRequestHistory.vueBotManagement.vueModelManagement.vueProcessManagement.vueSettings.vue 五个组件。

CUKd0Ik7-26.png

修改路由配置文件

关于子路由的写法,官方文档在这里:嵌套路由 | Vue Router

根据官方的模板进行修改。添加子路由,将历史模式设置为 createWebHistory()

CUKd0Ik7-27.png

设置路由对应页面渲染位置

和一级路由一样,同样需要将路由对应的页面在 Layout 组件中展示出来。

src/views/layout/index.vue 中的 Main 替换为 <RouterView>,将 <el-main> 高度设为 100vh,查看效果。

CUKd0Ik7-28.png

CUKd0Ik7-29.png

7、封装侧边栏

在后面编写功能页面的时候,左侧边栏是不动的,但右侧的功能页面长度可能会超出浏览器窗口,所以将侧边栏封装便于更多的调整整体页面布局(其实这一段是后边加的,本来没有封装的,不封装的话有些地方不好改😡)。

CUKd0Ik7-30.png

src/views/layout 目录下创建 Sidebar.vue 组件,将 index.vue 中侧边栏部分的代码剪切进去,再导入 Sidebar 组件,替换到原代码我位置。

CUKd0Ik7-21.png

CUKd0Ik7-32.png

8、编写首页

首页布局

成品差不多就是下面那个样子。

CUKd0Ik7-33.png

添加信息卡片

将信息卡片放在 Element Plus 提供的滚动条 <el-scrollbar> 中,当内容超出滚动条高度时,会出现滚动条。

每一个行信息都使用 Element Plus 提供的 <el-card> 存放。#header<el-card> 头部信息的插槽,用于定义卡片头部信息的样式。<span class="decor"> 为头部信息文字前面的蓝色长条装饰😋。

下面是 src/views/layout/Home.vue

<script setup>

</script>

<template>

    <!-- 主体页面 -->
    <el-scrollbar>
        <div class="home-page">

            <!-- 概览卡片 -->
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    概览
                </div>
            </el-card>

            <!-- 系统信息卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        系统信息
                    </div>
                </template>
            </el-card>

            <!-- 系统负荷卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        系统负荷
                    </div>
                </template>
            </el-card>

            <!-- LLM 统计卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        LLM 统计
                    </div>
                </template>
            </el-card>


        </div>
    </el-scrollbar>

</template>

<style scoped>

.home-page {
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    border-radius: 20px;
    --el-card-border-radius: 20px;
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

</style>

CUKd0Ik7-34.png

自定义卡片内容

创建 systemInfosystemLoadllmStats 三个 ref 数组来表示三个卡片中的信息(这里先暂时取一个固定的值,后面写后端的时候再改为实时获取🥰)。

每个卡片内部使用 <el-raw> 容器展示,并设置 md、sm、xs 不同窗口大小下 <el-col> 在 24 栅格中占的格数。

系统负荷使用 Element Plus 提供的 <el-progress> 进度条组件。

下面是 src/views/layout/Home.vue

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

const systemInfo = ref([
    {label: 'Mouse AI', value: 'v1.0.0'},
    {label: '操作系统', value: 'Ubuntu 22.04'},
    {label: 'node.js 版本', value: '20.18.0'},
])

const systemLoad = ref([
    {label: 'CPU', value: 56, total: 100},
    {label: '内存', value: 68, total: 128},
    {label: '存储', value: 44, total: 512}
])

const llmStats = ref([
    {label: '请求总数', value: '12,843'},
    {label: '成功率', value: '98.4%'},
    {label: 'Token 消耗', value: '1.2M'},
])
</script>

<template>

    <!-- 主体页面 -->
    <el-scrollbar>
        <div class="home-page">

            <!-- 概览卡片 -->
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    概览
                </div>
            </el-card>

            <!-- 系统信息卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        系统信息
                    </div>
                </template>
                <el-row :gutter="16">
                    <el-col
                        v-for="item in systemInfo"
                        :key="item.label"
                        :md="8"
                        :sm="12"
                        :xs="12"
                    >
                        <div class="info-card">
                            <div class="info-label">{{ item.label }}</div>
                            <div class="info-value">{{ item.value }}</div>
                        </div>
                    </el-col>
                </el-row>
            </el-card>

            <!-- 系统负荷卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        系统负荷
                    </div>
                </template>
                <el-row :gutter="24" justify="space-between">
                    <el-col
                        v-for="item in systemLoad"
                        :key="item.label"
                        :md="8"
                        :sm="12"
                        :xs="12"
                        class="load-card"
                    >
                        <el-progress
                            :percentage="item.value"
                            :stroke-width="8"
                            :width="140"
                            color="#3a7afe"
                            type="circle"
                        >
                            <span class="percentage-value">{{ item.value }}%</span>
                            <span class="percentage-label">{{ item.label }}</span>
                        </el-progress>
                        <div class="load-label">{{ item.value }} / {{item.total}}</div>
                    </el-col>
                </el-row>
            </el-card>

            <!-- LLM 统计卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        LLM 统计
                    </div>
                </template>
                <el-row :gutter="16">
                    <el-col
                        v-for="item in llmStats"
                        :key="item.label"
                        :md="8"
                        :sm="12"
                        :xs="12"
                    >
                        <div class="stat-card">
                            <div class="stat-label">{{ item.label }}</div>
                            <div class="stat-value">{{ item.value }}</div>
                        </div>
                    </el-col>
                </el-row>
            </el-card>


        </div>
    </el-scrollbar>

</template>

<style scoped>

.home-page {
    margin-left:100px;
    margin-right:100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.info-card {
    border-radius: 16px;
    padding: 18px;
    margin-bottom: 16px;
    text-align: center;
}

.info-label {
    font-size: 16px;
    color: #606266;
    margin-bottom: 6px;
}

.info-value {
    font-size: 18px;
    font-weight: 600;
    color: #409EFF;
}

.load-card {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
    padding: 16px 0;
}

.load-label {
    font-weight: 600;
    color: #606266;
}

.percentage-value {
    display: block;
    margin-top: 10px;
    font-size: 28px;
}
.percentage-label {
    display: block;
    margin-top: 10px;
    font-size: 12px;
}

.stat-card {
    border-radius: 16px;
    padding: 18px;
    margin-bottom: 16px;
    background-color: #fff;
    text-align: center;
}

.stat-label {
    font-size: 16px;
    color: #606266;
    margin-bottom: 6px;
}

.stat-value {
    font-size: 20px;
    font-weight: 700;
    color: #409EFF;
}
</style>

CUKd0Ik7-35.png

9、编写请求记录页

请求记录页布局

大概会做成下面这个样子。

CUKd0Ik7-36.png

添加信息卡片

创建 requestInfo ref 数组记录请求信息,使用 <el-row> 存放五个信息卡片,<el-row> 的排列方式默认为 flex,设置水平方向排列为 space-between。数据部分添加 :style="{ color: item.color },将 style 属性定义为一个变量,文字颜色为 requestInfo 中对应的值(颜色来源于 Color 色彩 | Element Plus)。

下面是 src/views/layout/RequestHistory.vue

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

const requestInfo = ref([
    {label: '总请求', value: '29', color: '#409EFF'},
    {label: '请求中', value: '0', color: '#E6A23C'},
    {label: '成功', value: '28', color: '#67C23A'},
    {label: '失败', value: '1', color: '#F56C6C'},
    {label: 'Token', value: '28829', color: '#909399'},
])
</script>

<template>

    <!-- 主体页面 -->
    <el-scrollbar>
        <div class="request-page">

            <!-- LLM 请求记录卡片 -->
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    LLM 请求
                </div>
            </el-card>

            <!-- 总请求信息 -->
            <el-row justify="space-between">
                <el-col
                    v-for="item in requestInfo"
                    :key="item.label"
                    :md="4"
                    :sm="10"
                    :xs="24"
                    class="request-item"
                >
                    <el-card class="card">
                        <div class="info-card">
                            <div class="info-label">{{ item.label }}</div>
                            <div class="info-value" :style="{ color: item.color }">{{ item.value }}</div>
                        </div>
                    </el-card>
                </el-col>
            </el-row>

        </div>
    </el-scrollbar>
</template>

<style scoped>

.request-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.info-card {
    border-radius: 16px;
    margin-bottom: 16px;
    text-align: center;
}

.info-label {
    font-size: 16px;
    color: #606266;
    margin-bottom: 6px;
    white-space: nowrap;
}

.info-value {
    font-size: 18px;
    font-weight: 600;
    color: #409EFF;
}

.request-item {
    margin-bottom: 16px;
}

</style>

CUKd0Ik7-37.png

添加单条记录的分页框

使用 <el-card> 创建一个卡片里面放数据表格,卡片标题使用和编写首页时一样的样式。

记录数据表格使用 Element Plus 提供的 <el-table>data 属性绑定一个 ref 数组 requestData。使用<el-table-column> 添加之前页面草图展示的 5 个列。

切换分页的按钮使用 Element Plus 提供的 <el-pagination>,其中 v-model:current-page 为当前页面,v-model:page-size="pageSize" 为单页大小。@size-change@current-change 两个事件在单页大小和当前页面改变时触发。

<script setup> 中将要使用的变量创建好,方便后面连接到后端。

下面是 src/views/layout/RequestHistory.vue

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

const requestInfo = ref([
    {label: '总请求', value: '29', color: '#409EFF'},
    {label: '请求中', value: '0', color: '#E6A23C'},
    {label: '成功', value: '28', color: '#67C23A'},
    {label: '失败', value: '1', color: '#F56C6C'},
    {label: 'Token', value: '28829', color: '#909399'},
])

// 表格数据(后端接口获取)
const requestData = ref([])

// 分页变量
// 当前页数
const currentPage = ref(1)
// 每页显示条目个数
const pageSize = ref(10)
// 记录总数
const total = ref(0)

// 分页方法(调用后端接口)
// page-size 改变时触发
const handleSizeChange = (val) => {
    pageSize.value = val
    fetchData()
}
// current-page 改变时触发
const handleCurrentChange = (val) => {
    currentPage.value = val
    fetchData()
}

// 请求后端数据
const fetchData = async () => {
// 这里调用你的后端接口,例如 axios
// const res = await axios.get('/api/requests', {
//   params: { page: currentPage.value, size: pageSize.value }
// })
// requestData.value = res.data.records
// total.value = res.data.total
}
</script>

<template>

    <!-- 主体页面 -->
    <el-scrollbar>
        <div class="request-page">

            <!-- LLM 请求记录卡片 -->
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    LLM 请求
                </div>
            </el-card>

            <!-- 总请求信息 -->
            <el-row justify="space-between">
                <el-col
                    v-for="item in requestInfo"
                    :key="item.label"
                    :md="4"
                    :sm="10"
                    :xs="24"
                    class="request-item"
                >
                    <el-card class="card">
                        <div class="info-card">
                            <div class="info-label">{{ item.label }}</div>
                            <div :style="{ color: item.color }" class="info-value">{{ item.value }}</div>
                        </div>
                    </el-card>
                </el-col>
            </el-row>

            <!-- 单条记录分页框卡片 -->
            <el-card class="card">

                <!-- 卡片标题 -->
                <template #header>
                    <div class="card-header">
                        <span class="decor"></span>
                        LLM 记录
                    </div>
                </template>

                <!-- 记录表格 -->
                <el-table :data="requestData" border style="width: 100%">
                    <el-table-column label="模型" prop="model"/>
                    <el-table-column label="机器人" prop="bot"/>
                    <el-table-column label="请求时间" prop="requestTime"/>
                    <el-table-column label="响应时间" prop="responseTime"/>
                    <el-table-column label="状态" prop="status"/>
                    <el-table-column label="Token" prop="token"/>
                </el-table>

            </el-card>

            <!-- 分页按钮 -->
            <div class="pagination-block">
                <span class="total-text">共 {{ 400 }} 条记录</span>
                <el-pagination
                    v-model:current-page="currentPage"
                    v-model:page-size="pageSize"
                    :page-sizes="[10, 20, 50, 100]"
                    :total="400"
                    background
                    layout="sizes, prev, pager, next"
                    @size-change="handleSizeChange"
                    @current-change="handleCurrentChange"
                />
            </div>


        </div>
    </el-scrollbar>
</template>

<style scoped>

.request-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.info-card {
    border-radius: 16px;
    margin-bottom: 16px;
    text-align: center;
}

.info-label {
    font-size: 16px;
    color: #606266;
    margin-bottom: 6px;
    white-space: nowrap;
}

.info-value {
    font-size: 18px;
    font-weight: 600;
    color: #409EFF;
}

.request-item {
    margin-bottom: 16px;
}

.pagination-block {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.total-text {
    color: #606266;
    font-size: 14px;
    white-space: nowrap;
}


</style>

CUKd0Ik7-38.png

10、编写机器人管理页

机器人管理页布局

随便画一下了,如下。

CUKd0Ik7-39.png

引入 Element Plus 图标

后面有些地方需要用到一些常用图标(添加,编辑,删除等),Element Plus 就有提供,就不去麻烦一个个找了。Icon 图标 | Element Plus

npm install @element-plus/icons-vue :使用 npm 安装图标库。

CUKd0Ik7-40.png

CUKd0Ik7-41.png

如果想直接引入所有图标可以继续按官网的操作来,但我用到的图标比较少,可以在单个组件中像 import { Delete, Edit, Plus } from '@element-plus/icons-vue' 这样按需引入。

CUKd0Ik7-42.png

添加信息卡片

首先是标题卡片,和之前的页面一样,只是将概览换成配置。

然后在下面接着创建一个展示配置信息的卡片,使用 flex 布局,左边是配置列表,右边是配置详情。

<script setup>

</script>

<template>
    <el-scrollbar>

        <!-- 页面标题 -->
        <div class="bot-page">
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    配置
                </div>
            </el-card>

            <!-- 配置信息卡片 -->
            <el-card class="card">
                <div class="bot-layout">

                    <!-- 左侧配置列表 -->
                    <div class="list-panel">

                    </div>

                    <!-- 右侧详细配置 -->
                    <div class="details-panel">

                    </div>
                </div>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.bot-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.bot-layout {
    display: flex;
    overflow-x: auto;
    gap: 24px;
}

.list-panel,
.details-panel {
    flex-grow: 1;
    background: #f8f9ff;
    border-radius: 16px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.list-panel {
    max-width: 320px;
}
</style>

CUKd0Ik7-43.png

编写配置页面样式

在标题卡片后同样添加一个展示卡片,卡片内部使用 flex 布局,左边为配置列表容器,右边为配置详情容器

首先是配置列表容器。

配置列表容器标题后添加一个添加配置按钮,绑定 handleAdd 事件。

创建一个临时的静态 configs 变量(以后是要从后端获取的),如下:

const configs = ref([
    {
        id: 'bot-001',
        name: '客服助手',
        enabled: true,
        appId: 'APP-202401',
        appSecret: 'SECRET-58FJ2',
        token: 'TOKEN-91XZ3',
        sandbox: true,
        image: '/src/assets/images/little-mouse.png',
    },
    {
        id: 'bot-002',
        name: '销售助理',
        enabled: false,
        appId: '',
        appSecret: '',
        token: '',
        sandbox: true,
        image: '/src/assets/images/little-mouse.png',
    },
])

配置列表中的各配置项放在 <el-scrollbar> 无限滚动容器的容器中。

其中各配置项的容器为:

<div
    v-for="item in configs"
    :key="item.id"
    :class="['config-card', { active: selectedId === item.id }]"
    @click="handleSelect(item.id)"
>

使用 v-for 进行列表渲染,id 作为识别 key,添加 config-card 样式,创建 selectedId 变量用于表示被选中的配置项,当某个配置项被选中时添加 active 样式,点击绑定 handleSelect() 事件,传入 item.id

每个配置项里面会展示该配置项的图片,名称,一个编辑按钮,一个删除按钮。

给编辑按钮绑定 handleEdit() 事件,传入 item,删除按钮绑定 handleDelete() 事件,传入 item

然后是详情容器容器。

标题和配置列表一样,直接复制就行了。

下面的展示框表单使用 v-ifv-else 条件渲染,用于处理有无配置项被选中的情况。

如果有配置项被选中,使用 Element Plus 提供的表单组件 <el-form> 来展示信息,创建 currentConfig(这里先用 const currentConfig = configs.value[0] 演示)用于表示当前被选中的配置项。

其中回调地址一项使用固定格式 https://<网页地址>/bots/<机器人ID>/callback,所以统一使用使用变量 callbackUrl,并创建一个生成回调地址的函数 generateCallbackUrl()

创建 isDisabled 变量,表单的每一项都添加 :disabled="isDisabled" 属性,isDisabled 用于控制表单是否可以修改。

表单最后有两个按钮,保存和重置,分别绑定 submitForm()resetForm() 事件,传入当前表单实例 configFormRef

整体代码如下:

<script setup>

import {Delete, Edit, Plus} from '@element-plus/icons-vue'
import {ref} from "vue";

const configs = ref([
    {
        id: 'bot-001',
        name: '客服助手',
        enabled: true,
        appId: 'APP-202401',
        appSecret: 'SECRET-58FJ2',
        token: 'TOKEN-91XZ3',
        sandbox: true,
        image: '/src/assets/images/little-mouse.png',
    },
    {
        id: 'bot-002',
        name: '销售助理',
        enabled: false,
        appId: '',
        appSecret: '',
        token: '',
        sandbox: true,
        image: '/src/assets/images/little-mouse.png',
    },
])

const currentConfig = configs.value[0]

</script>

<template>
    <el-scrollbar>

        <!-- 页面标题 -->
        <div class="bot-page">
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    配置
                </div>
            </el-card>

            <!-- 配置信息卡片 -->
            <el-card class="card">
                <div class="bot-layout">

                    <!-- 左侧配置列表 -->
                    <div class="list-panel">

                        <!-- 头部信息 -->
                        <div class="panel-header">
                            <!-- 标题 -->
                            <div class="panel-title">
                                <span class="decor"></span>
                                配置列表
                            </div>
                            <!-- 添加配置按钮  -->
                            <el-button :icon="Plus" plain type="primary" @click="handleAdd">新增配置</el-button>
                        </div>


                        <el-scrollbar class="list-scroll">
                            <div
                                v-for="item in configs"
                                :key="item.id"
                                :class="['config-card', { active: selectedId === item.id }]"
                                @click="handleSelect(item.id)"
                            >
                                <div class="config-body">
                                    <el-image :src="item.image" fit="cover"/>
                                    <div class="config-content">
                                        <div class="config-name">{{ item.name }}</div>
                                        <el-tag :type="item.enabled ? 'success' : 'info'" size="small">
                                            {{ item.enabled ? '运行中' : '已停用' }}
                                        </el-tag>
                                    </div>
                                </div>

                                <div class="config-actions">
                                    <el-button
                                        :disabled="item.id !== selectedId"
                                        :icon="Edit"
                                        circle
                                        text
                                        type="primary"
                                        @click.stop="handleEdit(item)"
                                    />
                                    <el-button
                                        :icon="Delete"
                                        circle
                                        text
                                        type="danger"
                                        @click.stop="handleDelete(item)"
                                    />
                                </div>
                            </div>
                        </el-scrollbar>
                    </div>

                    <!-- 右侧详细配置 -->
                    <div class="details-panel">
                        <div class="panel-title">
                            <span class="decor"></span>
                            配置详情
                        </div>
                        <div v-if="currentConfig" class="detail-content">
                            <el-form :model="currentConfig" label-position="top" label-width="96px">
                                <!-- 机器人名称 -->
                                <el-form-item label="名称">
                                    <el-input v-model="currentConfig.name" :disabled="isDisabled"
                                              placeholder="请输入机器人名称"/>
                                </el-form-item>

                                <!-- 开启按钮 -->
                                <el-form-item label="开启">
                                    <el-switch
                                        v-model="currentConfig.enabled"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- App ID -->
                                <el-form-item label="App ID">
                                    <el-input v-model="currentConfig.appId" :disabled="isDisabled"
                                              placeholder="请输入 App ID"/>
                                </el-form-item>

                                <!-- App Secret -->
                                <el-form-item label="App Secret">
                                    <el-input v-model="currentConfig.appSecret" :disabled="isDisabled"
                                              placeholder="请输入 App Secret"/>
                                </el-form-item>

                                <!-- Token -->
                                <el-form-item label="Token">
                                    <el-input v-model="currentConfig.token" :disabled="isDisabled"
                                              placeholder="请输入 Token"/>
                                </el-form-item>
                                
                                <!-- 沙盒环境 -->
                                <el-form-item label="沙盒环境">
                                    <el-switch
                                        v-model="currentConfig.sandbox"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- 回调 URL -->
                                <el-form-item label="回调 URL">
                                    <el-input :model-value="callbackUrl" disabled/>
                                </el-form-item>

                                <!-- 保存与重置按钮 -->
                                <el-form-item>
                                    <el-button :disabled="isDisabled" type="primary" @click="submitForm(configFormRef)">
                                        保存
                                    </el-button>
                                    <el-button :disabled="isDisabled" @click="resetForm(configFormRef)">
                                        重置
                                    </el-button>
                                </el-form-item>

                            </el-form>
                        </div>
                        <div v-else class="empty-state">
                            <el-empty description="请先创建或选择一个配置"/>
                        </div>
                    </div>
                </div>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.bot-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor,
.panel-title .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.bot-layout {
    display: flex;
    overflow-x: auto;
    gap: 24px;
}

.list-panel,
.details-panel {
    flex-grow: 1;
    background: #f8f9ff;
    border-radius: 16px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.list-panel {
    max-width: 320px;
}

.panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.panel-title {
    display: flex;
    align-items: center;
    font-weight: 600;
}

.list-scroll {
    max-height: 420px;
    padding-right: 8px;
}

.config-card {
    border-radius: 16px;
    padding: 16px;
    background: #fff;
    box-shadow: 0 10px 24px rgba(64, 158, 255, 0.08);
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16px;
    margin-bottom: 16px;
    cursor: pointer;
    transition: all 0.2s ease;
}

.config-card:last-child {
    margin-bottom: 0;
}

.config-card.active {
    border: 1px solid #409eff;
    box-shadow: 0 16px 30px rgba(64, 158, 255, 0.18);
}

.config-card:hover {
    transform: translateY(-4px);
}

.config-body {
    display: flex;
    align-items: center;
    gap: 12px;
}

.config-body .el-image {
    width: 56px;
    height: 56px;
    border-radius: 14px;
}

.config-content {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.config-name {
    font-weight: 600;
    font-size: 16px;
    color: #303133;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
    

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

CUKd0Ik7-44.png

处理配置页面的变量和事件

下面处理页面元素绑定的变量和事件,也就是 <script setup> 中的部分。

<template> 部分创建了以下事件和变量:

  • configs 变量,所有配置文件的集合。

    // 配置文件列表
    const configs = ref([])
    
    // 获取配置文件
    const getConfigs = () => {
        configs.value.push({
                id: 'bot-001',
                name: '客服助手',
                enabled: false,
                appId: 'APP-202401',
                appSecret: 'SECRET-58FJ2',
                token: 'TOKEN-91XZ3',
            	sandbox: true,
                image: '/src/assets/images/little-mouse.png',
            },
            {
                id: 'bot-002',
                name: '销售助理',
                enabled: false,
                appId: '',
                appSecret: '',
                token: '',
            	sandbox: true,
                image: '/src/assets/images/little-mouse.png',
            },)
    }
    
    onMounted(() => {
        getConfigs()
    })
    

​ 创建 configs 后,将赋值重新写一个 getConfigs() 函数,便于写完后端后使用这个函数调用,目前还是依然使用固定值。

​ 使用 onMounted() 组件,在组件加载时先调用一次 getConfigs()

  • handleAdd() 事件,当配置列表的添加配置按钮被点击时触发,用于处理添加配置项。

    // 添加配置时的行为
    const handleAdd = () => {
        const index = configs.value.length + 1
        const id = `bot-${Date.now()}`
        const name = `机器人配置 ${index}`
        const image = '/src/assets/images/little-mouse.png'
    
        configs.value.push({
            id,
            name,
            enabled: false,
            appId: '',
            appSecret: '',
            token: '',
            sandbox: true,
            image,
        })
    
        handleSelect(id)
        ElMessage.success('已新增机器人配置')
    }
    

    触发 handleAdd() 后会在 configs 后新增一个配置项,然后触发 handleSelect(id) 即选择刚刚新建的配置项。最后会弹出创建的提示。

  • selectedId 变量,用于表示被选中的配置项。

    // 当前选中的配置ID
    const selectedId = ref(configs.value[0]?.id ?? '')
    

    ?.可选链操作符。如果 configs.value[0] 有值,就取它的 id,如果 configs.value[0]undefined(比如数组为空),整个表达式会直接返回 undefined,不会报错。

    ??空值合并运算符。如果前面的结果是 nullundefined,就用右边的默认值 ''。保证 selectedId 不会是 undefined,至少是个空字符串。

  • handleSelect() 事件,当左侧的某个配置项被点击时触发,用于切换右侧的详细配置。

    // 选择其他配置时的行为
    const handleSelect = (id) => {
        selectedId.value = id
        isDisabled.value = true
        currentConfig.value = configs.value.find(item => item.id === id)
    }
    

    触发 handleSelect() 后会将 selectedId 即被选中的配置项切换为传入的 id 的配置项。由于切换了配置项,那么当前配置项应该不能被编辑,所以 isDisabled 的值设为 true。将当前选中的配置信息 currentConfig 设为 configs 中的 id 与传入 id 相等的项。

  • handleEdit() 事件,点击配置项的编辑按钮时触发,用于处理配置项编辑。

    // 点击编辑按钮时的行为
    const handleEdit = (item) => {
        isDisabled.value = false
        ElMessage.success(`正在编辑「${item.name}」`)
    }
    

    触发 handleEdit() 后会将 isDisabled 设为 false,即表单可以被编辑。并且弹出提示。

  • handleDelete() 事件,点击配置项的删除按钮时触发,用于处理配置项删除。

    // 删除配置时的行为
    const handleDelete = (config) => {
        ElMessageBox.confirm(`确认删除「${config.name}」吗?`, '提示', {
            confirmButtonText: '确定',
            cancelButtonText: '取消',
            type: 'warning',
        })
            .then(() => {
                const index = configs.value.findIndex((item) => item.id === config.id)
                if (index !== -1) {
                    configs.value.splice(index, 1)
                    if (selectedId.value === config.id) {
                        selectedId.value = configs.value[0]?.id ?? ''
                    }
                    ElMessage.success('删除成功')
                }
            })
            .catch(() => {
            })
    }
    

    触发 handleDelete() 后会弹出是否删除的警告,如果确认则会在 configs 中根据索引删除传入的 config 配置项。然后如果 selectedId(当前选中的配置的 id)等于 config.id(你点击删除的那个配置的 id),那就把 selectedId 切换到新的默认值(数组的第一个配置的 id,如果数组为空就设成空字符串)。最后提示删除成功。

  • currentConfig 变量,用于储存当前选中的配置信息。

    // 当前选中的配置文件
    const currentConfig = ref()
    

    默认为空。

  • callbackUrl 变量,用于表示当前配置的回调地址。

    // 实时更新回调地址,如果没有则调用生成函数
    const callbackUrl = computed(() => (currentConfig.value ? generateCallbackUrl(currentConfig.value.id) : ''))
    

    computedVue 3 提供的计算属性,用来声明一个基于其它响应式数据自动计算的值

    currentConfig 的值存在时,则调用 generateCallbackUrl() 将自身的值赋为依据当前配置的 id 生成的地址,不存在则赋为空。

  • generateCallbackUrl() 函数,根据传入的 id 生成回调地址。

    // 生成回调地址的函数
    const generateCallbackUrl = (id) => `https://<网页地址>/bots/${id}/callback`
    

    生成格式如上。

  • isDisabled 变量,用于表示当前配置项是否可以被编辑

    // 是否允许编辑配置
    const isDisabled = ref(true)
    

    默认不允许编辑,需要点击编辑按钮。

submitForm()resetForm() 事件,以及 configFormRef 变量和表单验证,在接下来的表单验证部分介绍。

下面是完整代码:

<script setup>

import {Delete, Edit, Plus} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {computed, onMounted, ref} from "vue";

// 配置文件列表
const configs = ref([])

// 获取配置文件
const getConfigs = () => {
    configs.value.push({
            id: 'bot-001',
            name: '客服助手',
            enabled: true,
            appId: 'APP-202401',
            appSecret: 'SECRET-58FJ2',
            token: 'TOKEN-91XZ3',
            sandbox: true,
            image: '/src/assets/images/little-mouse.png',
        },
        {
            id: 'bot-002',
            name: '销售助理',
            enabled: false,
            appId: '',
            appSecret: '',
            token: '',
            sandbox: true,
            image: '/src/assets/images/little-mouse.png',
        },)
}


// 当前选中的配置ID
const selectedId = ref(configs.value[0]?.id ?? '')

// 当前选中的配置文件
const currentConfig = ref()

// 选择其他配置时的行为
const handleSelect = (id) => {
    selectedId.value = id
    isDisabled.value = true
    currentConfig.value = configs.value.find(item => item.id === id)
}

// 添加配置时的行为
const handleAdd = () => {
    const index = configs.value.length + 1
    const id = `bot-${Date.now()}`
    const name = `机器人配置 ${index}`
    const image = '/src/assets/images/little-mouse.png'

    configs.value.push({
        id,
        name,
        enabled: false,
        appId: '',
        appSecret: '',
        token: '',
        sandbox: true,
        image,
    })

    handleSelect(id)
    ElMessage.success('已新增机器人配置')
}

// 点击编辑按钮时的行为
const handleEdit = (item) => {
    isDisabled.value = false
    ElMessage.success(`正在编辑「${item.name}」`)
}

// 删除配置时的行为
const handleDelete = (config) => {
    ElMessageBox.confirm(`确认删除「${config.name}」吗?`, '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
    })
        .then(() => {
            const index = configs.value.findIndex((item) => item.id === config.id)
            if (index !== -1) {
                configs.value.splice(index, 1)
                if (selectedId.value === config.id) {
                    selectedId.value = configs.value[0]?.id ?? ''
                }
                ElMessage.success('删除成功')
            }
        })
        .catch(() => {
        })
}

// 是否允许编辑配置
const isDisabled = ref(true)

// 生成回调地址的函数
const generateCallbackUrl = (id) => `https://<网页地址>/bots/${id}/callback`

// 实时更新回调地址,如果没有则调用生成函数
const callbackUrl = computed(() => (currentConfig.value ? generateCallbackUrl(currentConfig.value.id) : ''))

// 表单实例
const configFormRef = ref()

// 表单校验规则
const rules = ref({
    name: [
        { required: true, message: '名称不能为空!', trigger: 'blur' },
    ],
    appId: [
        { required: true, message: 'App ID 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[0-9]+$/; // 纯数字
                if (!value) {
                    callback(new Error('App ID 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('App ID 为纯数字!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    appSecret: [
        { required: true, message: 'App Secret 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[A-Za-z0-9]{32}$/; // 32位字母数字
                if (!value) {
                    callback(new Error('App Secret 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('App Secret 为32位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    token: [
        { required: true, message: 'Token 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[A-Za-z0-9]{32}$/; // 32位字母数字
                if (!value) {
                    callback(new Error('Token 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('Token 为32位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
})

// 保存表单
const submitForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            isDisabled.value = true
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}

// 重置表单
const resetForm = (formRef) => {
    if (!formRef || !currentConfig.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

onMounted(() => {
    getConfigs()
})

</script>

<template>
    <el-scrollbar>

        <!-- 页面标题 -->
        <div class="bot-page">
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    配置
                </div>
            </el-card>

            <!-- 配置信息卡片 -->
            <el-card class="card">
                <div class="bot-layout">

                    <!-- 左侧配置列表 -->
                    <div class="list-panel">

                        <!-- 头部信息 -->
                        <div class="panel-header">
                            <!-- 标题 -->
                            <div class="panel-title">
                                <span class="decor"></span>
                                配置列表
                            </div>
                            <!-- 添加配置按钮  -->
                            <el-button :icon="Plus" plain type="primary" @click="handleAdd">新增配置</el-button>
                        </div>


                        <el-scrollbar class="list-scroll">
                            <div
                                v-for="item in configs"
                                :key="item.id"
                                :class="['config-card', { active: selectedId === item.id }]"
                                @click="handleSelect(item.id)"
                            >
                                <div class="config-body">
                                    <el-image :src="item.image" fit="cover"/>
                                    <div class="config-content">
                                        <div class="config-name">{{ item.name }}</div>
                                        <el-tag :type="item.enabled ? 'success' : 'info'" size="small">
                                            {{ item.enabled ? '运行中' : '已停用' }}
                                        </el-tag>
                                    </div>
                                </div>

                                <div class="config-actions">
                                    <el-button
                                        :disabled="item.id !== selectedId"
                                        :icon="Edit"
                                        circle
                                        text
                                        type="primary"
                                        @click.stop="handleEdit(item)"
                                    />
                                    <el-button
                                        :icon="Delete"
                                        circle
                                        text
                                        type="danger"
                                        @click.stop="handleDelete(item)"
                                    />
                                </div>
                            </div>
                        </el-scrollbar>
                    </div>

                    <!-- 右侧详细配置 -->
                    <div class="details-panel">
                        <div class="panel-title">
                            <span class="decor"></span>
                            配置详情
                        </div>
                        <div v-if="currentConfig" class="detail-content">
                            <el-form ref="configFormRef" :model="currentConfig" :rules="rules" label-position="top"
                                     label-width="96px">
                                <!-- 机器人名称 -->
                                <el-form-item label="名称" prop="name">
                                    <el-input v-model="currentConfig.name" :disabled="isDisabled"
                                              placeholder="请输入机器人名称"/>
                                </el-form-item>

                                <!-- 开启按钮 -->
                                <el-form-item label="开启">
                                    <el-switch
                                        v-model="currentConfig.enabled"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- App ID -->
                                <el-form-item label="App ID" prop="appId">
                                    <el-input v-model="currentConfig.appId" :disabled="isDisabled"
                                              placeholder="请输入 App ID"/>
                                </el-form-item>

                                <!-- App Secret -->
                                <el-form-item label="App Secret" prop="appSecret">
                                    <el-input v-model="currentConfig.appSecret" :disabled="isDisabled"
                                              placeholder="请输入 App Secret"/>
                                </el-form-item>

                                <!-- Token -->
                                <el-form-item label="Token" prop="token">
                                    <el-input v-model="currentConfig.token" :disabled="isDisabled"
                                              placeholder="请输入 Token"/>
                                </el-form-item>

                                <!-- 沙盒环境 -->
                                <el-form-item label="沙盒环境">
                                    <el-switch
                                        v-model="currentConfig.sandbox"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- 回调 URL -->
                                <el-form-item label="回调 URL">
                                    <el-input :model-value="callbackUrl" disabled/>
                                </el-form-item>

                                <!-- 保存与重置按钮 -->
                                <el-form-item>
                                    <el-button :disabled="isDisabled" type="primary" @click="submitForm(configFormRef)">
                                        保存
                                    </el-button>
                                    <el-button :disabled="isDisabled" @click="resetForm(configFormRef)">
                                        重置
                                    </el-button>
                                </el-form-item>

                            </el-form>
                        </div>
                        <div v-else class="empty-state">
                            <el-empty description="请先创建或选择一个配置"/>
                        </div>
                    </div>
                </div>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.bot-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor,
.panel-title .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.bot-layout {
    display: flex;
    overflow-x: auto;
    gap: 24px;
}

.list-panel,
.details-panel {
    flex-grow: 1;
    background: #f8f9ff;
    border-radius: 16px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.list-panel {
    max-width: 320px;
}

.panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.panel-title {
    display: flex;
    align-items: center;
    font-weight: 600;
}

.list-scroll {
    max-height: 420px;
    padding-right: 8px;
}

.config-card {
    border-radius: 16px;
    padding: 16px;
    background: #fff;
    box-shadow: 0 10px 24px rgba(64, 158, 255, 0.08);
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16px;
    margin-bottom: 16px;
    cursor: pointer;
    transition: all 0.2s ease;
}

.config-card:last-child {
    margin-bottom: 0;
}

.config-card.active {
    border: 1px solid #409eff;
    box-shadow: 0 16px 30px rgba(64, 158, 255, 0.18);
}

.config-card:hover {
    transform: translateY(-4px);
}

.config-body {
    display: flex;
    align-items: center;
    gap: 12px;
}

.config-body .el-image {
    width: 56px;
    height: 56px;
    border-radius: 14px;
}

.config-content {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.config-name {
    font-weight: 600;
    font-size: 16px;
    color: #303133;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

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

给配置详情添加表单验证

当修改配置时,最好在前端加一个表单验证。比如 App ID、App Secret、Token 一定不会是中文或者空值且有一定的位数限制,如果输入了明显非法的值应该在前端就拒绝,减轻后端的压力。

官方关于表单验证的文档在这里:Form 表单 | Element Plus,但是只给了例子,并没有很详细的介绍用法🤔。

首先创建一个表单实例 configFormRef,这个变量就相当于整个表单组件,使用 <el-form> 中的 ref 属性将它和表单组件绑定。

再创建一个 json 变量 rules,这个变量储存的是对于表单组件的验证规则,使用 <el-form> 中的 :rules 属性将它和表单组件绑定。

<el-form> 表单中需要添加校验的 <el-form-item> 表单项使用 prop 绑定一个用于识别的键,json 变量 rules 中也创建一个相同的键,这个键的值就是校验规则。

这是修改后的表单组件。

CUKd0Ik7-45.png

然后是 rules 的写法,其中的每一个键代表该键的校验规则。这个键的值是一个 json 数组,每一个 json 代表一个校验规则,数组表示该键可以有多个校验规则。一个校验规则 json 中可用的字段可以在 Element Plus 使用的开源校验器的项目 Readme 中找到:yiminghe/async-validator: validate form asynchronous 。Element Plus 自带的只有一个 trigger,值为 blur 时会在输入框失去焦点时触发验证,值为 change 时会在输入框的值发生变化时就触发验证。

下面是我的写法:

// 表单校验规则
const rules = ref({
    name: [
        { required: true, message: '名称不能为空!', trigger: 'blur' },
    ],
    appId: [
        { required: true, message: 'App ID 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[0-9]+$/; // 纯数字
                if (!value) {
                    callback(new Error('App ID 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('App ID 为纯数字!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    appSecret: [
        { required: true, message: 'App Secret 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[A-Za-z0-9]{32}$/; // 32位字母数字
                if (!value) {
                    callback(new Error('App Secret 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('App Secret 为32位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    token: [
        { required: true, message: 'Token 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[A-Za-z0-9]{32}$/; // 32位字母数字
                if (!value) {
                    callback(new Error('Token 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('Token 为32位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
})

每个字段都有不为空的规则验证, App ID、App Secret、Token 额外添加了一个验证器来自定义验证规则。validator: (rule, value, callback) 在上面给出的官方文档有介绍,这里简单讲一下。value 为输入框的内容,一般就是对这个变量进行验证,如果验证通过直接调用 callback() 函数,失败则像 callback(new Error('Error!')) 这样往 callback() 里面传一个 Error() 对象。

完整代码如下:

<script setup>

import {Delete, Edit, Plus} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {computed, onMounted, ref} from "vue";

// 配置文件列表
const configs = ref([])

// 获取配置文件
const getConfigs = () => {
    configs.value.push({
            id: 'bot-001',
            name: '客服助手',
            enabled: true,
            appId: 'APP-202401',
            appSecret: 'SECRET-58FJ2',
            token: 'TOKEN-91XZ3',
            sandbox: true,
            image: '/src/assets/images/little-mouse.png',
        },
        {
            id: 'bot-002',
            name: '销售助理',
            enabled: false,
            appId: '',
            appSecret: '',
            token: '',
            sandbox: true,
            image: '/src/assets/images/little-mouse.png',
        },)
}


// 当前选中的配置ID
const selectedId = ref(configs.value[0]?.id ?? '')

// 当前选中的配置文件
const currentConfig = ref()

// 选择其他配置时的行为
const handleSelect = (id) => {
    selectedId.value = id
    isDisabled.value = true
    currentConfig.value = configs.value.find(item => item.id === id)
}

// 添加配置时的行为
const handleAdd = () => {
    const index = configs.value.length + 1
    const id = `bot-${Date.now()}`
    const name = `机器人配置 ${index}`
    const image = '/src/assets/images/little-mouse.png'

    configs.value.push({
        id,
        name,
        enabled: false,
        appId: '',
        appSecret: '',
        token: '',
        sandbox: true,
        image,
    })

    handleSelect(id)
    ElMessage.success('已新增机器人配置')
}

// 点击编辑按钮时的行为
const handleEdit = (item) => {
    isDisabled.value = false
    ElMessage.success(`正在编辑「${item.name}」`)
}

// 删除配置时的行为
const handleDelete = (config) => {
    ElMessageBox.confirm(`确认删除「${config.name}」吗?`, '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
    })
        .then(() => {
            const index = configs.value.findIndex((item) => item.id === config.id)
            if (index !== -1) {
                configs.value.splice(index, 1)
                if (selectedId.value === config.id) {
                    selectedId.value = configs.value[0]?.id ?? ''
                }
                ElMessage.success('删除成功')
            }
        })
        .catch(() => {
        })
}

// 是否允许编辑配置
const isDisabled = ref(true)

// 生成回调地址的函数
const generateCallbackUrl = (id) => `https://<网页地址>/bots/${id}/callback`

// 实时更新回调地址,如果没有则调用生成函数
const callbackUrl = computed(() => (currentConfig.value ? generateCallbackUrl(currentConfig.value.id) : ''))

// 表单实例
const configFormRef = ref()

// 表单校验规则
const rules = ref({
    name: [
        { required: true, message: '名称不能为空!', trigger: 'blur' },
    ],
    appId: [
        { required: true, message: 'App ID 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[0-9]+$/; // 纯数字
                if (!value) {
                    callback(new Error('App ID 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('App ID 为纯数字!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    appSecret: [
        { required: true, message: 'App Secret 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[A-Za-z0-9]{32}$/; // 32位字母数字
                if (!value) {
                    callback(new Error('App Secret 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('App Secret 为32位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    token: [
        { required: true, message: 'Token 不能为空!', trigger: 'blur' },
        {
            validator: (rule, value, callback) => {
                const reg = /^[A-Za-z0-9]{32}$/; // 32位字母数字
                if (!value) {
                    callback(new Error('Token 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('Token 为32位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
})


onMounted(() => {
    getConfigs()
})

</script>

<template>
    <el-scrollbar>

        <!-- 页面标题 -->
        <div class="bot-page">
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    配置
                </div>
            </el-card>

            <!-- 配置信息卡片 -->
            <el-card class="card">
                <div class="bot-layout">

                    <!-- 左侧配置列表 -->
                    <div class="list-panel">

                        <!-- 头部信息 -->
                        <div class="panel-header">
                            <!-- 标题 -->
                            <div class="panel-title">
                                <span class="decor"></span>
                                配置列表
                            </div>
                            <!-- 添加配置按钮  -->
                            <el-button :icon="Plus" plain type="primary" @click="handleAdd">新增配置</el-button>
                        </div>


                        <el-scrollbar class="list-scroll">
                            <div
                                v-for="item in configs"
                                :key="item.id"
                                :class="['config-card', { active: selectedId === item.id }]"
                                @click="handleSelect(item.id)"
                            >
                                <div class="config-body">
                                    <el-image :src="item.image" fit="cover"/>
                                    <div class="config-content">
                                        <div class="config-name">{{ item.name }}</div>
                                        <el-tag :type="item.enabled ? 'success' : 'info'" size="small">
                                            {{ item.enabled ? '运行中' : '已停用' }}
                                        </el-tag>
                                    </div>
                                </div>

                                <div class="config-actions">
                                    <el-button
                                        :disabled="item.id !== selectedId"
                                        :icon="Edit"
                                        circle
                                        text
                                        type="primary"
                                        @click.stop="handleEdit(item)"
                                    />
                                    <el-button
                                        :icon="Delete"
                                        circle
                                        text
                                        type="danger"
                                        @click.stop="handleDelete(item)"
                                    />
                                </div>
                            </div>
                        </el-scrollbar>
                    </div>

                    <!-- 右侧详细配置 -->
                    <div class="details-panel">
                        <div class="panel-title">
                            <span class="decor"></span>
                            配置详情
                        </div>
                        <div v-if="currentConfig" class="detail-content">
                            <el-form ref="configFormRef" :model="currentConfig" :rules="rules" label-position="top"
                                     label-width="96px">
                                <!-- 机器人名称 -->
                                <el-form-item label="名称" prop="name">
                                    <el-input v-model="currentConfig.name" :disabled="isDisabled"
                                              placeholder="请输入机器人名称"/>
                                </el-form-item>

                                <!-- 开启按钮 -->
                                <el-form-item label="开启">
                                    <el-switch
                                        v-model="currentConfig.enabled"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- App ID -->
                                <el-form-item label="App ID" prop="appId">
                                    <el-input v-model="currentConfig.appId" :disabled="isDisabled"
                                              placeholder="请输入 App ID"/>
                                </el-form-item>

                                <!-- App Secret -->
                                <el-form-item label="App Secret" prop="appSecret">
                                    <el-input v-model="currentConfig.appSecret" :disabled="isDisabled"
                                              placeholder="请输入 App Secret"/>
                                </el-form-item>

                                <!-- Token -->
                                <el-form-item label="Token" prop="token">
                                    <el-input v-model="currentConfig.token" :disabled="isDisabled"
                                              placeholder="请输入 Token"/>
                                </el-form-item>

                                <!-- 沙盒环境 -->
                                <el-form-item label="沙盒环境">
                                    <el-switch
                                        v-model="currentConfig.sandbox"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- 回调 URL -->
                                <el-form-item label="回调 URL">
                                    <el-input :model-value="callbackUrl" disabled/>
                                </el-form-item>

                                <!-- 保存与重置按钮 -->
                                <el-form-item>
                                    <el-button :disabled="isDisabled" type="primary" @click="submitForm(configFormRef)">
                                        保存
                                    </el-button>
                                    <el-button :disabled="isDisabled" @click="resetForm(configFormRef)">
                                        重置
                                    </el-button>
                                </el-form-item>

                            </el-form>
                        </div>
                        <div v-else class="empty-state">
                            <el-empty description="请先创建或选择一个配置"/>
                        </div>
                    </div>
                </div>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.bot-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor,
.panel-title .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.bot-layout {
    display: flex;
    overflow-x: auto;
    gap: 24px;
}

.list-panel,
.details-panel {
    flex-grow: 1;
    background: #f8f9ff;
    border-radius: 16px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.list-panel {
    max-width: 320px;
}

.panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.panel-title {
    display: flex;
    align-items: center;
    font-weight: 600;
}

.list-scroll {
    max-height: 420px;
    padding-right: 8px;
}

.config-card {
    border-radius: 16px;
    padding: 16px;
    background: #fff;
    box-shadow: 0 10px 24px rgba(64, 158, 255, 0.08);
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16px;
    margin-bottom: 16px;
    cursor: pointer;
    transition: all 0.2s ease;
}

.config-card:last-child {
    margin-bottom: 0;
}

.config-card.active {
    border: 1px solid #409eff;
    box-shadow: 0 16px 30px rgba(64, 158, 255, 0.18);
}

.config-card:hover {
    transform: translateY(-4px);
}

.config-body {
    display: flex;
    align-items: center;
    gap: 12px;
}

.config-body .el-image {
    width: 56px;
    height: 56px;
    border-radius: 14px;
}

.config-content {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.config-name {
    font-weight: 600;
    font-size: 16px;
    color: #303133;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

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

CUKd0Ik7-46.png

11、编写大模型管理页

这里和机器人管理页几乎是一样的,这里我们需要的信息有 4 个:名称、开启按钮、Base URL、API Key、模型。

Base URL 一般为 https://api.deepseek.com/v1,API Key 在 DeekSeek API 平台自动生成,模型当前有 deepseek-chatdeepseek-reasoner 可选,具体见官方文档:首次调用 API | DeepSeek API Docs

所以复制机器人管理页的代码,在 src/assets/images 添加一个 DeepSeek 的图标当默认图标(iconfont 有下载),自行稍加修改就可以了(真的只是一点点改动😃,API Key 的验证规则改为 sk- 开头的 32 位数字字母,Base URL 暂时固定所以创建时直接赋值且表单设置为不可修改)。

src/views/layout/ModelManagement.vue 完整文件如下:

<script setup>
import {Delete, Edit, Plus} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {onMounted, ref} from "vue"

// 配置文件列表
const configs = ref([])

// 获取配置文件
const getConfigs = () => {
    configs.value.push(
        {
            id: 'model-001',
            name: '测试模型1',
            enabled: true,
            baseUrl: 'https://api.deepseek.com/v1',
            apiKey: 'sk-15c040979d3e4b26abae62b09d3adfd5',
            model: 'deepseek-chat',
            image: '/src/assets/images/deepseek.png',
        },
        {
            id: 'model-002',
            name: '测试模型2',
            enabled: false,
            baseUrl: 'https://api.deepseek.com/v1',
            apiKey: '',
            model: 'deepseek-reasoner',
            image: '/src/assets/images/deepseek.png',
        },
    )
}

// 当前选中的配置ID
const selectedId = ref(configs.value[0]?.id ?? '')

// 当前选中的配置文件
const currentConfig = ref()

// 是否允许编辑配置
const isDisabled = ref(true)

// 选择其他配置
const handleSelect = (id) => {
    selectedId.value = id
    isDisabled.value = true
    currentConfig.value = configs.value.find(item => item.id === id)
}

// 添加配置
const handleAdd = () => {
    const index = configs.value.length + 1
    const id = `model-${Date.now()}`
    const name = `模型配置 ${index}`
    const image = '/src/assets/images/deepseek.png'

    configs.value.push({
        id,
        name,
        enabled: false,
        baseUrl: 'https://api.deepseek.com/v1',
        apiKey: '',
        model: 'deepseek-chat',
        image,
    })

    handleSelect(id)
    ElMessage.success('已新增模型配置')
}

// 编辑配置
const handleEdit = (item) => {
    isDisabled.value = false
    ElMessage.success(`正在编辑「${item.name}」`)
}

// 删除配置
const handleDelete = (config) => {
    ElMessageBox.confirm(`确认删除「${config.name}」吗?`, '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
    })
        .then(() => {
            const index = configs.value.findIndex((item) => item.id === config.id)
            if (index !== -1) {
                configs.value.splice(index, 1)
                if (selectedId.value === config.id) {
                    selectedId.value = configs.value[0]?.id ?? ''
                    currentConfig.value = configs.value[0] ?? null
                }
                ElMessage.success('删除成功')
            }
        })
        .catch(() => {
        })
}

// 表单实例
const configFormRef = ref()

// 表单校验规则
const rules = ref({
    name: [
        {required: true, message: '名称不能为空!', trigger: 'blur'},
    ],
    baseUrl: [
        {required: true, message: 'Base URL 不能为空!', trigger: 'blur'},
    ],
    apiKey: [
        {required: true, message: 'API Key 不能为空!', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                // 格式:sk-开头 + 字母数字组合
                const reg = /^sk-[A-Za-z0-9]+$/
                if (!value) {
                    callback(new Error('API Key 不能为空!'))
                } else if (!reg.test(value)) {
                    callback(new Error('API Key 必须以 sk- 开头,后面为 32 位字母数字组合!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur'
        }
    ],
    model: [
        {required: true, message: '请选择模型!', trigger: 'change'}
    ]
})

// 保存表单
const submitForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            isDisabled.value = true
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}

// 重置表单
const resetForm = (formRef) => {
    if (!formRef || !currentConfig.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

onMounted(() => {
    getConfigs()
})
</script>

<template>
    <el-scrollbar>
        <div class="model-page">
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    配置
                </div>
            </el-card>

            <el-card class="card">
                <div class="model-layout">
                    <!-- 左侧配置列表 -->
                    <div class="list-panel">
                        <div class="panel-header">
                            <div class="panel-title">
                                <span class="decor"></span>
                                模型列表
                            </div>
                            <el-button :icon="Plus" plain type="primary" @click="handleAdd">新增模型</el-button>
                        </div>

                        <el-scrollbar class="list-scroll">
                            <div
                                v-for="item in configs"
                                :key="item.id"
                                :class="['config-card', { active: selectedId === item.id }]"
                                @click="handleSelect(item.id)"
                            >
                                <div class="config-body">
                                    <el-image :src="item.image" fit="cover"/>
                                    <div class="config-content">
                                        <div class="config-name">{{ item.name }}</div>
                                        <el-tag :type="item.enabled ? 'success' : 'info'" size="small">
                                            {{ item.enabled ? '运行中' : '已停用' }}
                                        </el-tag>
                                    </div>
                                </div>
                                <div class="config-actions">
                                    <el-button
                                        :disabled="item.id !== selectedId"
                                        :icon="Edit"
                                        circle
                                        text
                                        type="primary"
                                        @click.stop="handleEdit(item)"
                                    />
                                    <el-button
                                        :icon="Delete"
                                        circle
                                        text
                                        type="danger"
                                        @click.stop="handleDelete(item)"
                                    />
                                </div>
                            </div>
                        </el-scrollbar>
                    </div>

                    <!-- 右侧详细配置 -->
                    <div class="details-panel">
                        <div class="panel-title">
                            <span class="decor"></span>
                            模型详情
                        </div>
                        <div v-if="currentConfig" class="detail-content">
                            <el-form ref="configFormRef" :model="currentConfig" :rules="rules" label-position="top"
                                     label-width="96px">
                                <el-form-item label="名称" prop="name">
                                    <el-input v-model="currentConfig.name" :disabled="isDisabled"
                                              placeholder="请输入模型名称"/>
                                </el-form-item>

                                <el-form-item label="开启">
                                    <el-switch
                                        v-model="currentConfig.enabled"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <el-form-item label="Base URL" prop="baseUrl">
                                    <el-input v-model="currentConfig.baseUrl" :disabled="true"
                                              placeholder="请输入 Base URL"/>
                                </el-form-item>

                                <el-form-item label="API Key" prop="apiKey">
                                    <el-input v-model="currentConfig.apiKey" :disabled="isDisabled"
                                              placeholder="请输入 API Key"/>
                                </el-form-item>

                                <el-form-item label="模型" prop="model">
                                    <el-select v-model="currentConfig.model" :disabled="isDisabled"
                                               placeholder="请选择模型">
                                        <el-option label="deepseek-chat" value="deepseek-chat"/>
                                        <el-option label="deepseek-reasoner" value="deepseek-reasoner"/>
                                    </el-select>
                                </el-form-item>

                                <el-form-item>
                                    <el-button :disabled="isDisabled" type="primary" @click="submitForm(configFormRef)">
                                        保存
                                    </el-button>
                                    <el-button :disabled="isDisabled" @click="resetForm(configFormRef)">
                                        重置
                                    </el-button>
                                </el-form-item>
                            </el-form>
                        </div>
                        <div v-else class="empty-state">
                            <el-empty description="请先创建或选择一个配置"/>
                        </div>
                    </div>
                </div>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.model-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor,
.panel-title .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.model-layout {
    display: flex;
    overflow-x: auto;
    gap: 24px;
}

.list-panel,
.details-panel {
    flex-grow: 1;
    background: #f8f9ff;
    border-radius: 16px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.list-panel {
    max-width: 320px;
}

.panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.panel-title {
    display: flex;
    align-items: center;
    font-weight: 600;
}

.list-scroll {
    max-height: 420px;
    padding-right: 8px;
}

.config-card {
    border-radius: 16px;
    padding: 16px;
    background: #fff;
    box-shadow: 0 10px 24px rgba(64, 158, 255, 0.08);
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16px;
    margin-bottom: 16px;
    cursor: pointer;
    transition: all 0.2s ease;
}

.config-card:last-child {
    margin-bottom: 0;
}

.config-card.active {
    border: 1px solid #409eff;
    box-shadow: 0 16px 30px rgba(64, 158, 255, 0.18);
}

.config-card:hover {
    transform: translateY(-4px);
}

.config-body {
    display: flex;
    align-items: center;
    gap: 12px;
}

.config-body .el-image {
    width: 56px;
    height: 56px;
    border-radius: 14px;
}

.config-content {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.config-name {
    font-weight: 600;
    font-size: 16px;
    color: #303133;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

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

CUKd0Ik7-47.png

12、编写流程管理页

现在已经有了 QQ 机器人配置和 AI 大模型配置,现在需要一个流程管理,当获取到 QQ 群里面的消息时,能调用 AI 大模型做成对应的回应。

我的想法是这样的,每一个流程首先必须选择已经创建的 QQ 机器人对象。毕竟你可以不用 AI,但总归到底还是要在 QQ 群进行回复的。

然后可以选择模板,这里模板分两种:角色模板和功能模板。角色模板即可以调用 AI 来进行角色扮演(说是叫角色扮演,但毕竟是可以自定义预设的,你想让他干什么都行),功能模板则是通过代码来实现固定的功能。

同时,提供一些预设模板,可以选择自定义也可以选择预设模板。预设模板的本质上就是将自定义的输入框给禁用掉,在里面填上预设。

当模板为功能模板时,会额外出现触发命令输入框,需要在 @ 机器人后跟 /<命令> 来触发对应的功能。而角色模板则不需要触发命令,直接 @ 与其对话就可以了。

前端样式基本和前面两个管理页面一样,主要是前端逻辑。

对比前两个页面,主要增加了以下变量和事件:

  • currentTemplateOptions 变量,查看当前模板种类中的可用模板。

    // 查看当前模板种类中的模板
    const currentTemplateOptions = computed(() => {
        // 获取当前配置模板类型
        const type = currentConfig.value?.templateType
        // type ? ... : [] -- 如果 type 有值(比如 "AITemplate"),就执行 ...,否则直接返回 []
        // templateOptions.value[type] ?? [] -- 从 templateOptions 中取当前模板种类中的模板。如果取不到(是 null 或 undefined),就返回空数组 []。
        return type ? templateOptions.value[type] ?? [] : []
    })
    

    前面说了,模板种类有两个,分别是角色模板和功能模板,每个模板种类下有很多模板,currentTemplateOptions 就是用于获取当前选择模板种类下的所有模板。

  • isAiTemplateisFunctionTemplateshowTriggerFieldshowCodeFieldshowModelFieldshowRoleFieldisRoleEditableisCodeEditable 变量,和输入框显示有关。

    // 根据模板类型控制字段可见性与可编辑性
    // 判断模板类型是否为 AiTemplate
    const isAiTemplate = computed(() => currentConfig.value?.templateType === 'AITemplate')
    // 判断模板类型是否为 functionTemplate
    const isFunctionTemplate = computed(() => currentConfig.value?.templateType === 'functionTemplate')
    /// 如果模板是 AiTemplate,不显示 触发命令 输入框
    const showTriggerField = computed(() => !isAiTemplate.value)
    // 如果模板是 AiTemplate,不显示 代码注入 输入框
    const showCodeField = computed(() => !isAiTemplate.value)
    // 如果模板是 functionTemplate,不显示 模型 输入框
    const showModelField = computed(() => !isFunctionTemplate.value)
    // 如果模板是 AiTemplate,显示 角色描述 输入框
    const showRoleField = computed(() => isAiTemplate.value)
    // 如果模板是 AiTemplate 并且选择的是 "自定义角色(ai-custom)",角色描述可编辑
    const isRoleEditable = computed(() =>
        isAiTemplate.value && currentConfig.value?.template === 'ai-custom'
    )
    // 如果模板是 functionTemplate 并且选择的是 "自定义功能(function-custom)",代码注入可编辑
    const isCodeEditable = computed(() =>
        isFunctionTemplate.value && currentConfig.value?.template === 'function-custom'
    )
    

    模板是角色模板时,就不会出现触发命令、代码注入输入框;模板是功能模板时,就不会出现模型、角色描述输入框。当不是自定义模板时,两个模板种类的自定义输入框(角色描述、代码注入)都将不可编辑。

  • watch 监听事件,当模板种类从角色模板切换到功能模板时,自动获取切换后模板种类的第一个模板。

    // 监听模板类型变化,当模板从 AiTemplate 切换到 functionTemplate 时自动获取切换后模板的第一个值
    watch(() => currentConfig.value?.templateType, (type) => {
        // 如果当前配置为空,或者模板类型为空,就直接退出
        if (!currentConfig.value || !type) return
        // 获取当前类型下所有可用的模板列表
        const available = templateOptions.value[type] ?? []
        // 如果当前配置的 template 不在可用列表中
        if (!available.find(item => item.value === currentConfig.value.template)) {
            // 自动切换为该类型下的第一个模板
            currentConfig.value.template = available[0]?.value ?? ''
        }
    })
    

下面是完整代码:

<script setup>
import {Delete, Edit, Plus} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {computed, onMounted, ref, watch} from 'vue'

// 流程配置列表
const configs = ref([])

// 当前流程配置
const currentConfig = ref()

// 是否允许编辑
const isDisabled = ref(true)

// 模板类型
const templateTypeOptions = ref([
    {value: 'AITemplate', label: '角色模板'},
    {value: 'functionTemplate', label: '功能模板'},
])

// 模板选项
const templateOptions = ref({
    AITemplate: [
        {value: 'ai-custom', label: '自定义角色'},
        {value: 'catgirl', label: '猫娘'},
    ],
    functionTemplate: [
        {value: 'function-custom', label: '自定义功能'},
        {value: 'life_assistant', label: '生活助手'},
        {value: 'delta_force_assistant', label: '三角洲游戏助手'},
    ],
})

// 查看当前模板种类中的模板
const currentTemplateOptions = computed(() => {
    // 获取当前配置模板类型
    const type = currentConfig.value?.templateType
    // type ? ... : [] -- 如果 type 有值(比如 "AITemplate"),就执行 ...,否则直接返回 []
    // templateOptions.value[type] ?? [] -- 从 templateOptions 中取当前模板种类中的模板。如果取不到(是 null 或 undefined),就返回空数组 []。
    return type ? templateOptions.value[type] ?? [] : []
})

// 获取模板类型名称
const getTemplateTypeLabel = (type) => {
    return templateTypeOptions.value.find(item => item.value === type)?.label ?? ''
}

// 机器人列表(来自机器人管理)
const botOptions = ref([
    {label: '客服助手', value: 'bot-001'},
    {label: '销售助理', value: 'bot-002'},
    {label: '技术支持', value: 'bot-003'},
])

// 大模型列表(来自模型管理)
const modelOptions = ref([
    {label: 'deepseek-chat', value: 'deepseek-chat'},
    {label: 'deepseek-reasoner', value: 'deepseek-reasoner'},
])

// 当前选中的流程 ID
const selectedId = ref(configs.value[0]?.id ?? '')

// 表单实例
const configFormRef = ref()

// 获取流程配置列表
const getConfigs = () => {
    configs.value.push(
        {
            id: 'process-001',
            name: '猫娘',
            enabled: true,
            // 新增:区分模板类型
            templateType: 'AITemplate',
            template: 'catgirl',
            botId: 'bot-001',
            modelId: 'deepseek-chat',
            triggerCommand: '',
            codeInjection: '/* 自定义逻辑 */',
            role: '你是一只可爱活泼的猫娘,喜欢卖萌。',
            image: '/src/assets/images/mouse.png',
        },
        {
            id: 'process-002',
            name: '小助手',
            enabled: false,
            // 新增:区分模板类型
            templateType: 'functionTemplate',
            template: 'life_assistant',
            botId: 'bot-002',
            modelId: 'deepseek-reasoner',
            triggerCommand: '/life_assistant',
            codeInjection: '',
            role: '',
            image: '/src/assets/images/mouse.png',
        },
        {
            id: 'process-003',
            name: '三角洲行动游戏助手',
            enabled: true,
            // 新增:区分模板类型
            templateType: 'functionTemplate',
            template: 'delta_force_assistant',
            botId: 'bot-003',
            modelId: 'deepseek-chat',
            triggerCommand: '/delta_force_assistant',
            codeInjection: '',
            role: '',
            image: '/src/assets/images/mouse.png',
        },
    )
}

// 选择流程
const handleSelect = (id) => {
    selectedId.value = id
    isDisabled.value = true
    currentConfig.value = configs.value.find(item => item.id === id)
}

// 新增流程
const handleAdd = () => {
    const index = configs.value.length + 1
    const id = `process-${Date.now()}`
    const defaultBot = botOptions.value[0]?.value ?? ''
    const defaultModel = modelOptions.value[0]?.value ?? ''

    const newProcess = {
        id,
        name: `新建流程 ${index}`,
        enabled: false,
        // 新增:新建流程默认使用 AI 自定义模板
        templateType: 'AITemplate',
        template: 'ai-custom',
        botId: defaultBot,
        modelId: defaultModel,
        triggerCommand: `/flow${index}`,
        codeInjection: '',
        role: '',
        image: '/src/assets/images/mouse.png',
    }

    configs.value.push(newProcess)
    handleSelect(id)
    ElMessage.success('已新增流程配置')
}

// 编辑流程
const handleEdit = (item) => {
    isDisabled.value = false
    ElMessage.success(`正在编辑「${item.name}」`)
}

// 删除流程
const handleDelete = (config) => {
    ElMessageBox.confirm(`确认删除「${config.name}」吗?`, '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
    })
        .then(() => {
            const index = configs.value.findIndex((item) => item.id === config.id)
            if (index !== -1) {
                configs.value.splice(index, 1)
                if (selectedId.value === config.id) {
                    selectedId.value = configs.value[0]?.id ?? ''
                    currentConfig.value = configs.value[0] ?? null
                }
                ElMessage.success('删除成功')
            }
        })
        .catch(() => {
        })
}

// 表单校验规则
const rules = ref({
    name: [
        {required: true, message: '名称不能为空!', trigger: 'blur'},
    ],
    // 新增:校验模板类型
    templateType: [
        {required: true, message: '请选择模板类型!', trigger: 'change'},
    ],
    template: [
        {required: true, message: '请选择流程模板!', trigger: 'change'},
    ],
    botId: [
        {required: true, message: '请选择机器人!', trigger: 'change'},
    ],
    modelId: [
        {required: true, message: '请选择模型!', trigger: 'change'},
    ],
    triggerCommand: [
        {required: true, message: '触发命令不能为空!', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                if (!value) {
                    callback(new Error('触发命令不能为空!'))
                } else if (!value.startsWith('/')) {
                    callback(new Error('触发命令必须以 / 开头!'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
})

// 根据模板类型控制字段可见性与可编辑性
// 判断模板类型是否为 AiTemplate
const isAiTemplate = computed(() => currentConfig.value?.templateType === 'AITemplate')
// 判断模板类型是否为 functionTemplate
const isFunctionTemplate = computed(() => currentConfig.value?.templateType === 'functionTemplate')
/// 如果模板是 AiTemplate,不显示 触发命令 输入框
const showTriggerField = computed(() => !isAiTemplate.value)
// 如果模板是 AiTemplate,不显示 代码注入 输入框
const showCodeField = computed(() => !isAiTemplate.value)
// 如果模板是 functionTemplate,不显示 模型 输入框
const showModelField = computed(() => !isFunctionTemplate.value)
// 如果模板是 AiTemplate,显示 角色描述 输入框
const showRoleField = computed(() => isAiTemplate.value)
// 如果模板是 AiTemplate 并且选择的是 "自定义角色(ai-custom)",角色描述可编辑
const isRoleEditable = computed(() =>
    isAiTemplate.value && currentConfig.value?.template === 'ai-custom'
)
// 如果模板是 functionTemplate 并且选择的是 "自定义功能(function-custom)",代码注入可编辑
const isCodeEditable = computed(() =>
    isFunctionTemplate.value && currentConfig.value?.template === 'function-custom'
)

// 监听模板类型变化,当模板从 AiTemplate 切换到 functionTemplate 时自动获取切换后模板的第一个值
watch(() => currentConfig.value?.templateType, (type) => {
    // 如果当前配置为空,或者模板类型为空,就直接退出
    if (!currentConfig.value || !type) return
    // 获取当前类型下所有可用的模板列表
    const available = templateOptions.value[type] ?? []
    // 如果当前配置的 template 不在可用列表中
    if (!available.find(item => item.value === currentConfig.value.template)) {
        // 自动切换为该类型下的第一个模板
        currentConfig.value.template = available[0]?.value ?? ''
    }
})

// 保存表单
const submitForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            isDisabled.value = true
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}

// 重置表单
const resetForm = (formRef) => {
    if (!formRef || !currentConfig.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

onMounted(() => {
    getConfigs()

})
</script>

<template>
    <el-scrollbar>
        <div class="process-page">
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    流程管理
                </div>
            </el-card>

            <el-card class="card">
                <div class="process-layout">
                    <!-- 左侧流程列表 -->
                    <div class="list-panel">
                        <div class="panel-header">
                            <div class="panel-title">
                                <span class="decor"></span>
                                流程列表
                            </div>
                            <el-button :icon="Plus" plain type="primary" @click="handleAdd">新增流程</el-button>
                        </div>

                        <el-scrollbar class="list-scroll">
                            <div
                                v-for="item in configs"
                                :key="item.id"
                                :class="['config-card', { active: selectedId === item.id }]"
                                @click="handleSelect(item.id)"
                            >
                                <div class="config-body">
                                    <el-image :src="item.image" fit="cover"/>
                                    <div class="config-content">
                                        <div class="config-name">{{ item.name }}</div>
                                        <!-- 新增:展示模板类型与名称 -->
                                        <div class="config-meta">
                                            <el-tag size="small">
                                                {{ getTemplateTypeLabel(item.templateType) }}
                                            </el-tag>
                                            <el-tag :type="item.enabled ? 'success' : 'info'" size="small">
                                                {{ item.enabled ? '运行中' : '已停用' }}
                                            </el-tag>
                                        </div>
                                    </div>
                                </div>
                                <div class="config-actions">
                                    <el-button
                                        :disabled="item.id !== selectedId"
                                        :icon="Edit"
                                        circle
                                        text
                                        type="primary"
                                        @click.stop="handleEdit(item)"
                                    />
                                    <el-button
                                        :icon="Delete"
                                        circle
                                        text
                                        type="danger"
                                        @click.stop="handleDelete(item)"
                                    />
                                </div>
                            </div>
                        </el-scrollbar>
                    </div>

                    <!-- 右侧流程详情 -->
                    <div class="details-panel">
                        <div class="panel-title">
                            <span class="decor"></span>
                            流程详情
                        </div>
                        <div v-if="currentConfig" class="detail-content">
                            <el-form
                                ref="configFormRef"
                                :model="currentConfig"
                                :rules="rules"
                                label-position="top"
                                label-width="96px"
                            >
                                <el-form-item label="名称" prop="name">
                                    <el-input
                                        v-model="currentConfig.name"
                                        :disabled="isDisabled"
                                        placeholder="请输入流程名称"
                                    />
                                </el-form-item>

                                <el-form-item label="开启">
                                    <el-switch
                                        v-model="currentConfig.enabled"
                                        :disabled="isDisabled"
                                        active-color="#67C23A"
                                        inactive-color="#909399"
                                    />
                                </el-form-item>

                                <!-- 模板类型选择 -->
                                <el-form-item label="模板类型" prop="templateType">
                                    <el-select
                                        v-model="currentConfig.templateType"
                                        :disabled="isDisabled"
                                        placeholder="请选择模板类型"
                                    >
                                        <el-option
                                            v-for="item in templateTypeOptions"
                                            :key="item.value"
                                            :label="item.label"
                                            :value="item.value"
                                        />
                                    </el-select>
                                </el-form-item>

                                <!-- 模板选择 -->
                                <el-form-item label="模板" prop="template">
                                    <el-select
                                        v-model="currentConfig.template"
                                        :disabled="isDisabled"
                                        placeholder="请选择流程模板"
                                    >
                                        <el-option
                                            v-for="item in currentTemplateOptions"
                                            :key="item.value"
                                            :label="item.label"
                                            :value="item.value"
                                        />
                                    </el-select>
                                </el-form-item>

                                <!-- 机器人选择 -->
                                <el-form-item label="机器人" prop="botId">
                                    <el-select
                                        v-model="currentConfig.botId"
                                        :disabled="isDisabled"
                                        placeholder="请选择机器人"
                                    >
                                        <el-option
                                            v-for="bot in botOptions"
                                            :key="bot.value"
                                            :label="bot.label"
                                            :value="bot.value"
                                        />
                                    </el-select>
                                </el-form-item>

                                <!-- 模型选择 -->
                                <el-form-item v-if="showModelField" label="模型" prop="modelId">
                                    <el-select
                                        v-model="currentConfig.modelId"
                                        :disabled="isDisabled"
                                        placeholder="请选择模型"
                                    >
                                        <el-option
                                            v-for="model in modelOptions"
                                            :key="model.value"
                                            :label="model.label"
                                            :value="model.value"
                                        />
                                    </el-select>
                                </el-form-item>

                                <!-- 触发命令 -->
                                <el-form-item v-if="showTriggerField" label="触发命令" prop="triggerCommand">
                                    <el-input
                                        v-model="currentConfig.triggerCommand"
                                        :disabled="isDisabled"
                                        placeholder="请输入以 / 开头的命令"
                                    />
                                </el-form-item>

                                <!-- 代码注入 -->
                                <el-form-item v-if="showCodeField" label="代码注入">
                                    <el-input
                                        v-model="currentConfig.codeInjection"
                                        :autosize="{ minRows: 3 }"
                                        :disabled="isDisabled || !isCodeEditable"
                                        placeholder="请输入自定义代码"
                                        type="textarea"
                                    />
                                    <div v-if="isFunctionTemplate && currentConfig.template !== 'function-custom'" class="field-tip">
                                        仅自定义功能模板支持修改代码
                                    </div>
                                </el-form-item>

                                <!-- 角色描述 -->
                                <el-form-item v-if="showRoleField" label="角色描述" prop="role">
                                    <el-input
                                        v-model="currentConfig.role"
                                        :autosize="{ minRows: 3 }"
                                        :disabled="isDisabled || !isRoleEditable"
                                        placeholder="请输入 AI 扮演角色"
                                        type="textarea"
                                    />
                                    <div v-if="isAiTemplate && currentConfig.template !== 'ai-custom'" class="field-tip">
                                        仅自定义角色模板支持修改角色描述
                                    </div>
                                </el-form-item>

                                <el-form-item>
                                    <el-button
                                        :disabled="isDisabled"
                                        type="primary"
                                        @click="submitForm(configFormRef)"
                                    >
                                        保存
                                    </el-button>
                                    <el-button
                                        :disabled="isDisabled"
                                        @click="resetForm(configFormRef)"
                                    >
                                        重置
                                    </el-button>
                                </el-form-item>
                            </el-form>
                        </div>
                        <div v-else class="empty-state">
                            <el-empty description="请先创建或选择一个配置"/>
                        </div>
                    </div>
                </div>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.process-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.card-header .decor,
.panel-title .decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.process-layout {
    display: flex;
    overflow-x: auto;
    gap: 24px;
}

.list-panel,
.details-panel {
    flex-grow: 1;
    background: #f8f9ff;
    border-radius: 16px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.list-panel {
    max-width: 320px;
}

.panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.panel-title {
    display: flex;
    align-items: center;
    font-weight: 600;
}

.list-scroll {
    max-height: 420px;
    padding-right: 8px;
}

.config-card {
    border-radius: 16px;
    padding: 16px;
    background: #fff;
    box-shadow: 0 10px 24px rgba(64, 158, 255, 0.08);
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16px;
    margin-bottom: 16px;
    cursor: pointer;
    transition: all 0.2s ease;
}

.config-card:last-child {
    margin-bottom: 0;
}

.config-card.active {
    border: 1px solid #409eff;
    box-shadow: 0 16px 30px rgba(64, 158, 255, 0.18);
}

.config-card:hover {
    transform: translateY(-4px);
}

.config-body {
    display: flex;
    align-items: center;
    gap: 12px;
}

.config-body .el-image {
    width: 56px;
    height: 56px;
    border-radius: 14px;
}

.config-content {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.config-name {
    font-weight: 600;
    font-size: 16px;
    color: #303133;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.config-meta {
    display: flex;
    gap: 8px;
    align-items: center;
}

.config-actions {
    display: flex;
    gap: 8px;
}

.field-tip {
    margin-top: 6px;
    font-size: 12px;
    color: #909399;
}

.detail-content {
    background: #fff;
    border-radius: 16px;
    padding: 24px;
}
</style>

CUKd0Ik7-48.png

CUKd0Ik7-49.png

13、编写设置页

设置页面用于设置服务运行访问端口和登录密码管理两个功能。

设置页面布局

整体页面样式风格与前面的页面基本一致,三张卡片,一张标题,一张服务配置,一张密码管理,基本复制前面的代码稍加修改就可以了,这里不过多赘述了。

代码如下:

<script setup>

import {ElMessage} from 'element-plus'
import {ref} from 'vue'

// 服务配置和密码管理的表单实例
const serviceForm = ref()
const passwordForm = ref()

// 当前服务配置和密码
const currentService = ref({
    port: '5173',
    allowIp: '0.0.0.0',
})

const currentPassword = ref({
    oldPassword: '',
    newPassword: '',
    confirmPassword: '',
})

// 保存表单与重置表单
const submitServiceForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}
const resetServiceForm = (formRef) => {
    if (!formRef || !currentService.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

const submitPasswordForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}
const resetPasswordForm = (formRef) => {
    if (!formRef || !currentPassword.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

</script>

<template>
    <el-scrollbar>
        <div class="settings-page">

            <!-- 系统设置卡片 -->
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    系统设置
                </div>
            </el-card>

            <!-- 服务配置卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-title">
                        <span class="decor"></span>
                        服务配置
                    </div>
                </template>
                <el-form
                    ref="serviceForm"
                    :model="currentService"
                    :rules="serviceRules"
                    class="settings-form"
                    label-position="top"
                >
                    <el-form-item label="运行端口" prop="port">
                        <el-input v-model="currentService.port" placeholder="请输入运行端口"/>
                    </el-form-item>
                    <el-form-item label="允许访问的 IP" prop="allowIp">
                        <el-input v-model="currentService.allowIp" placeholder="例如:0.0.0.0"/>
                        <p class="form-tip">填写 0.0.0.0 即允许所有来源访问。</p>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" @click="submitServiceForm">
                            保存配置
                        </el-button>
                        <el-button @click="resetServiceForm">
                            重置
                        </el-button>
                    </el-form-item>
                </el-form>
            </el-card>

            <el-card class="card">
                <template #header>
                    <div class="card-title">
                        <span class="decor"></span>
                        密码修改
                    </div>
                </template>
                <el-form
                    ref="passwordForm"
                    :model="currentPassword"
                    :rules="passwordRules"
                    class="settings-form"
                    label-position="top"
                >
                    <el-form-item label="当前密码" prop="oldPassword">
                        <el-input v-model="currentPassword.oldPassword" placeholder="请输入当前密码"
                                  show-password/>
                    </el-form-item>
                    <el-form-item label="新密码" prop="newPassword">
                        <el-input v-model="currentPassword.newPassword" placeholder="请输入新密码"
                                  show-password/>
                    </el-form-item>
                    <el-form-item label="确认新密码" prop="confirmPassword">
                        <el-input v-model="currentPassword.confirmPassword" placeholder="请再次输入新密码"
                                  show-password/>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" @click="submitPasswordForm">
                            确认修改
                        </el-button>
                        <el-button @click="resetPasswordForm">
                            清空
                        </el-button>
                    </el-form-item>
                </el-form>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.settings-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header,
.card-title {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.settings-form {
    max-width: 520px;
}

.form-tip {
    margin: 8px 0 0;
    color: #909399;
    font-size: 12px;
}
</style>

CUKd0Ik7-50.png

处理服务配置和密码管理的变量和事件

由于有两个表单,提交表单和重置表单的按钮分别绑定 submitServiceFormresetServiceFormsubmitPasswordFormresetPasswordForm

currentServicecurrentPassword 用于保存当前配置信息。

serviceFormpasswordForm 用于绑定整个表单组件。

最后为 getCurrentService 用于获取当前的服务配置,在 onMounted() 组件加载时获取。

处理服务配置和密码管理的表单验证

创建 serviceRulespasswordRules 用于处理两个表单的验证。

// 表单验证规则
const serviceRules = {
    port: [
        {required: true, message: '请输入服务端口', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                const portNumber = Number(value)
                if (!Number.isInteger(portNumber)) {
                    callback(new Error('端口必须为整数'))
                } else if (portNumber < 1 || portNumber > 65535) {
                    callback(new Error('端口范围为 1-65535'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
    allowIp: [
        {required: true, message: '请输入允许访问的 IP', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                const ipv4Pattern = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/
                if (!value) {
                    callback(new Error('允许访问的 IP 不能为空'))
                } else if (value !== '0.0.0.0' && !ipv4Pattern.test(value)) {
                    callback(new Error('请输入合法的 IPv4 地址,或 0.0.0.0 代表全部'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
}
const passwordRules = {
    oldPassword: [
        {required: true, message: '请输入当前密码', trigger: 'blur'},
    ],
    newPassword: [
        {required: true, message: '请输入新密码', trigger: 'blur'},
        {min: 6, message: '密码长度至少 6 位', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                if (!value) {
                    callback(new Error('请再次输入新密码'))
                } else if (currentPassword.value.confirmPassword !== '') {
                    passwordForm.value.validateField('confirmPassword')
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        }
    ],
    confirmPassword: [
        {required: true, message: '请再次输入新密码', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                if (!value) {
                    callback(new Error('请再次输入新密码'))
                } else if (value !== currentPassword.value.newPassword) {
                    callback(new Error('两次输入的密码不一致'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
}

serviceRules:端口不能为空,必须是整数,范围为 1-65535;运行访问 IP 不能为空,除了为 0.0.0.0 时,必须符合 IPv4 的地址格式,使用正则表达式检查(正则表达式使用直接问 AI 就行了,当你发现一个问题能用正则表达式解决,你就有了两个问题😋)。

passwordRules:旧密码不能为空,新密码和确认密码必须相同,密码最短为六位。注意。newPasswordcurrentPassword.value.confirmPassword !== '' 代表当表单中确认密码字段不为空时,依旧执行passwordForm.value.validateField('confirmPassword') 调用确认密码的验证器,这是为了防止有人先填确认密码再填新密码😅。

以下是完整代码:

<script setup>

import {ElMessage} from 'element-plus'
import {onMounted, ref} from 'vue'

// 服务配置和密码管理的表单实例
const serviceForm = ref()
const passwordForm = ref()

// 当前服务配置和密码
const currentService = ref({
    port: '',
    allowIp: '',
})
const currentPassword = ref({
    oldPassword: '',
    newPassword: '',
    confirmPassword: '',
})

// 获取服务配置和密码
const getCurrentService = () => {
    currentService.value.port = '5173'
    currentService.value.allowIp = '0.0.0.0'
}

// 保存表单与重置表单
const submitServiceForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}
const resetServiceForm = (formRef) => {
    if (!formRef || !currentService.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

const submitPasswordForm = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            ElMessage.success('保存成功')
        } else {
            ElMessage.error('请检查表单输入是否正确!')
        }
    })
}
const resetPasswordForm = (formRef) => {
    if (!formRef || !currentPassword.value) return
    formRef.resetFields()
    ElMessage.success('已重置表单')
}

// 表单验证规则
const serviceRules = {
    port: [
        {required: true, message: '请输入服务端口', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                const portNumber = Number(value)
                if (!Number.isInteger(portNumber)) {
                    callback(new Error('端口必须为整数'))
                } else if (portNumber < 1 || portNumber > 65535) {
                    callback(new Error('端口范围为 1-65535'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
    allowIp: [
        {required: true, message: '请输入允许访问的 IP', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                const ipv4Pattern = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/
                if (!value) {
                    callback(new Error('允许访问的 IP 不能为空'))
                } else if (value !== '0.0.0.0' && !ipv4Pattern.test(value)) {
                    callback(new Error('请输入合法的 IPv4 地址,或 0.0.0.0 代表全部'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
}
const passwordRules = {
    oldPassword: [
        {required: true, message: '请输入当前密码', trigger: 'blur'},
        {min: 6, message: '密码长度至少 6 位', trigger: 'blur'},
    ],
    newPassword: [
        {required: true, message: '请输入新密码', trigger: 'blur'},
        {min: 6, message: '密码长度至少 6 位', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                if (!value) {
                    callback(new Error('请再次输入新密码'))
                } else if (currentPassword.value.confirmPassword !== '') {
                    passwordForm.value.validateField('confirmPassword')
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        }
    ],
    confirmPassword: [
        {required: true, message: '请再次输入新密码', trigger: 'blur'},
        {min: 6, message: '密码长度至少 6 位', trigger: 'blur'},
        {
            validator: (rule, value, callback) => {
                if (!value) {
                    callback(new Error('请再次输入新密码'))
                } else if (value !== currentPassword.value.newPassword) {
                    callback(new Error('两次输入的密码不一致'))
                } else {
                    callback()
                }
            },
            trigger: 'blur',
        },
    ],
}

onMounted(() => {
    getCurrentService();
})

</script>

<template>
    <el-scrollbar>
        <div class="settings-page">

            <!-- 系统设置卡片 -->
            <el-card class="card">
                <div class="card-header">
                    <span class="decor"></span>
                    系统设置
                </div>
            </el-card>

            <!-- 服务配置卡片 -->
            <el-card class="card">
                <template #header>
                    <div class="card-title">
                        <span class="decor"></span>
                        服务配置
                    </div>
                </template>
                <el-form
                    ref="serviceForm"
                    :model="currentService"
                    :rules="serviceRules"
                    class="settings-form"
                    label-position="top"
                >
                    <el-form-item label="运行端口" prop="port">
                        <el-input v-model="currentService.port" placeholder="请输入运行端口"/>
                    </el-form-item>
                    <el-form-item label="允许访问的 IP" prop="allowIp">
                        <el-input v-model="currentService.allowIp" placeholder="例如:0.0.0.0"/>
                        <p class="form-tip">填写 0.0.0.0 即允许所有来源访问。</p>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" @click="submitServiceForm">
                            保存配置
                        </el-button>
                        <el-button @click="resetServiceForm">
                            重置
                        </el-button>
                    </el-form-item>
                </el-form>
            </el-card>

            <el-card class="card">
                <template #header>
                    <div class="card-title">
                        <span class="decor"></span>
                        密码修改
                    </div>
                </template>
                <el-form
                    ref="passwordForm"
                    :model="currentPassword"
                    :rules="passwordRules"
                    class="settings-form"
                    label-position="top"
                >
                    <el-form-item label="当前密码" prop="oldPassword">
                        <el-input v-model="currentPassword.oldPassword" placeholder="请输入当前密码"
                                  show-password/>
                    </el-form-item>
                    <el-form-item label="新密码" prop="newPassword">
                        <el-input v-model="currentPassword.newPassword" placeholder="请输入新密码"
                                  show-password/>
                    </el-form-item>
                    <el-form-item label="确认新密码" prop="confirmPassword">
                        <el-input v-model="currentPassword.confirmPassword" placeholder="请再次输入新密码"
                                  show-password/>
                    </el-form-item>
                    <el-form-item>
                        <el-button type="primary" @click="submitPasswordForm">
                            确认修改
                        </el-button>
                        <el-button @click="resetPasswordForm">
                            清空
                        </el-button>
                    </el-form-item>
                </el-form>
            </el-card>
        </div>
    </el-scrollbar>
</template>

<style scoped>
.settings-page {
    margin-left: 100px;
    margin-right: 100px;
    padding: 24px;
    display: flex;
    flex-direction: column;
    gap: 24px;
}

.card {
    --el-card-border-radius: 20px;
}

.card:hover {
    transform: translateY(-6px);
    box-shadow: 0 20px 45px rgba(64, 158, 255, 0.15);
}

.card-header,
.card-title {
    display: flex;
    align-items: center;
    font-size: 18px;
    font-weight: 600;
    letter-spacing: 0.08em;
}

.decor {
    display: inline-block;
    width: 6px;
    height: 20px;
    background: rgb(51, 126, 204);
    border-radius: 3px;
    margin-right: 8px;
}

.settings-form {
    max-width: 520px;
}

.form-tip {
    margin: 8px 0 0;
    color: #909399;
    font-size: 12px;
}
</style>

CUKd0Ik7-51.png

14、编写登录页面

登录界面比较简单,这个项目没必要整什么多用户管理,整个密码输入框就行了。

登录页面布局

整个页面容器为 <div class="login-page">,高度 height: 100vh; 占满屏幕,display: flex;align-items: center;justify-content: center; 将其设为弹性盒子,里面元素(也就是登录框)垂直居中。position: relative; 的目的是将其作为后面渐变背景的包含块overflow: hidden; 隐藏溢出部分。background: linear-gradient(135deg, #f5f8ff 0%, #f0f4ff 48%, #f8fbff 100%); 为渐变背景。

后面的 <div class="background-gradient"></div><div class="background-blur background-blur--left"></div><div class="background-blur background-blur--right"></div> 为渐变背景装饰,位置属性 position: absolute; 以前面的 <div class="login-page">包含块

至于这两个渐变背景和渐变装饰颜色呈现的 CSS 我直接用 codex 生成的,挺复杂的,觉得好看就行了,没必要搞明白🤓。

由于 <script setup> 里面的内容很少,这里旧不贴样式代码了,放后面一起贴。

处理登录页面变量和事件

  • router 变量,路由实例,用于登录后切换页面。

    const router = useRouter()
    
  • loginFormRef 变量,绑定表单实例,用于表单验证。

    const loginFormRef = ref()
    
  • loginForm 变量,当前表单输入的信息。

    const loginForm = ref({
        password: '',
    })
    
  • rules 变量,表单验证规则。

    const rules = {
        password: [
            {required: true, message: '密码不能为空!', trigger: 'blur'},
            {min: 6, message: '密码长度至少 6 位', trigger: 'blur'},
        ],
    }
    
  • remember 变量,用于绑定是否记住密码。

    const remember = ref(true)
    
  • handleSubmit 事件,绑定登录按钮,处理登录。

    const handleSubmit = (formRef) => {
        if (!formRef) return
        formRef.validate((valid) => {
            if (valid) {
                ElMessage.success('登录成功')
                router.push('/')
            } else {
                ElMessage.error('密码错误!')
            }
        })
    }
    

    传入一个表单实例 formRef,当表单验证通过后,与后端密码匹配(这里暂时没写),如果匹配成功调用 router.push('/') 跳转到主页面。

<script setup>
import {ref} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage} from 'element-plus'
import {Lock} from '@element-plus/icons-vue'

const router = useRouter()
const loginFormRef = ref()

const loginForm = ref({
    password: '',
})

const rules = {
    password: [
        {required: true, message: '密码不能为空!', trigger: 'blur'},
        {min: 6, message: '密码长度至少 6 位', trigger: 'blur'},
    ],
}

const remember = ref(true)

const handleSubmit = (formRef) => {
    if (!formRef) return
    formRef.validate((valid) => {
        if (valid) {
            ElMessage.success('登录成功')
            router.push('/')
        } else {
            ElMessage.error('密码错误!')
        }
    })
}

</script>

<template>
    <div class="login-page">
        <div class="background-gradient"></div>
        <div class="background-blur background-blur--left"></div>
        <div class="background-blur background-blur--right"></div>

        <el-card class="login-card" shadow="always">
            <div class="card-header">
                <div class="logo">Mouse AI</div>
            </div>

            <el-form
                ref="loginFormRef"
                :model="loginForm"
                :rules="rules"
                class="login-form"
                label-position="top"
            >
                <el-form-item prop="password">
                    <el-input
                        v-model="loginForm.password"
                        placeholder="请输入密码"
                        show-password
                        size="large"
                        type="password"
                        @keyup.enter="handleSubmit"
                    >
                        <template #prefix>
                            <el-icon>
                                <Lock/>
                            </el-icon>
                        </template>
                    </el-input>
                </el-form-item>
            </el-form>

            <div class="form-options">
                <el-checkbox v-model="remember">记住我</el-checkbox>
                <el-link :underline="false" type="primary">忘记密码?</el-link>
            </div>

            <el-button
                class="login-button"
                round
                size="large"
                type="primary"
                @click="handleSubmit(loginFormRef)"
            >
                登录
            </el-button>

        </el-card>
    </div>
</template>

<style scoped>
.login-page {
    position: relative;
    height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(135deg, #f5f8ff 0%, #f0f4ff 48%, #f8fbff 100%);
    overflow: hidden;
}

.background-gradient {
    position: absolute;
    inset: 0;
    background: radial-gradient(circle at 20% 20%, rgba(58, 122, 254, 0.2), transparent 55%),
    radial-gradient(circle at 80% 0%, rgba(144, 202, 249, 0.25), transparent 50%),
    radial-gradient(circle at 50% 80%, rgba(105, 117, 255, 0.2), transparent 55%);
    filter: blur(0px);
    z-index: 1;
}

.background-blur {
    position: absolute;
    width: 360px;
    height: 360px;
    background: rgba(58, 122, 254, 0.18);
    filter: blur(120px);
    border-radius: 50%;
    z-index: 1;
}

.background-blur--left {
    top: 12%;
    left: -120px;
}

.background-blur--right {
    bottom: -80px;
    right: -90px;
}

.login-card {
    z-index: 2;
    width: 420px;
    padding: 32px 36px 40px;
    border-radius: 24px;
    box-shadow: 0 30px 80px rgba(58, 122, 254, 0.18);
    border: none;
    background: rgba(255, 255, 255, 0.92);
    backdrop-filter: blur(12px);
}

.card-header {
    text-align: center;
    margin-bottom: 32px;
}

.logo {
    font-size: 28px;
    font-weight: 700;
    letter-spacing: 0.12em;
    color: #3155ff;
}

.form-options {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 18px 0 18px;
    color: #909399;
}

.login-button {
    width: 100%;
    height: 48px;
    font-size: 16px;
    letter-spacing: 0.1em;
}

</style>

CUKd0Ik7-52.png

到这里前端就写完了,还有两周就要交项目了,后端还没有动😭,感觉任务很艰巨啊......

四、后端

1、配置项目结构

前端项目由于使用 Vue CLI 自动生成模板,项目结构基本不需要更改和配置。

后端的项目结构如下(只是示例,里面功能仅参考,并不是真的有),先将总体的目录结构创建出来。

little-mouse-ai-backend/
│
├── package.json               # 项目信息与依赖
├── node_modules/              # npm 自动生成
├── README.md                  # 项目说明文件(可选)
│
└── src/                       # 源代码目录(后端核心)
    ├── app.js                 # 应用入口(创建并启动服务器)
    │
    ├── config/                # 配置层(全局配置、数据库、环境变量等)
    │   ├── appConfig.js       # 应用基础配置(端口、应用名等)
    │   └── db.js              # 数据库连接逻辑
    │
    ├── models/                # 数据模型层(定义数据结构)
    │   └── userModel.js       # 用户模型
    │
    ├── utils/                 # 工具函数层(通用函数)
    │   └── logger.js          # 日志工具
    │
    ├── routes/                # 路由层(定义接口路径)
    │   ├── index.js           # 路由汇总
    │   ├── userRoutes.js      # 用户接口路由
    │   └── systemRoutes.js    # 系统接口路由
    │
    ├── controllers/           # 控制器层(处理请求逻辑)
    │   ├── userController.js  # 用户逻辑控制器
    │   └── systemController.js# 系统逻辑控制器
    │
    └── services/              # 服务层(业务逻辑或数据处理)
        ├── userService.js     # 用户业务逻辑
        └── systemService.js   # 系统业务逻辑

同时修改生成的 package.json 配置文件如下:

{
  "name": "little-mouse-ai-backend",
  "version": "1.0.0",
  "type": "module",
  "main": "src/app.js",
  "scripts": {
    "start": "node src/app.js"
  }
}
  • "name":项目名
  • "version":项目版本号
  • "type": "module":让 Node.js 支持现代的 ES Module (ESM) 语法(import / export),不加的话只能使用旧的 CommonJS (CJS) 语法(require() / module.exports)。前端 Vue 项目使用的 ESM 语法,这里保持一致。
  • "main":主入口文件。
  • "scripts":自定义命令,比如使用 npm start 相当于执行 node src/app.js

CUKd0Ik7-53.png

2、使用 Express 开发 API 接口

接下来需要为前端创建 API 接口访问。

安装 Express

Express 是一个简洁灵活的 Node.js Web 应用框架,能让你能让你快速、简单地创建服务器和 API 接口

非常的轻量简单,Express 官网主页就有安装命令和简单模板。

CUKd0Ik7-54.png

使用 npm install express --save 安装 Express,复制官方模板,将语法改为 ESM 语法。

使用 npm install cors 安装 CORS,导入 CORS,添加 app.use(cors({origin: 'http://localhost:5173'})) 解决前端跨域问题。

跨域简单来说就是前端网页无法直接访问不同源(源 = 协议 + 域名 + 端口)的资源。

比如你在浏览器中登录了支付宝,而另一个恶意网站试图直接访问你当前打开的支付宝网页中的资源来获取你的账户信息,浏览器会因为跨域限制而拦截这次请求,从而防止数据泄露。

现在前端地址为 http://localhost:5173,后端地址为http://localhost:3000,显然两个端口不一样,自然要进行跨域处理。

添加 app.use(express.json())。默认情况下,Express 不会自动解析请求体,很多时候前端发送的数据都是 json 格式的,如果不添加这条语句的话 json 格式的数据将会被解析为 undefined

import express from 'express'
import cors from 'cors'

const app = express()
const port = 3000

// 允许跨域请求
app.use(cors({
    origin: 'http://localhost:5173',  // 允许的前端地址
}))

// 解析前端发送的 JSON 数据
app.use(express.json())

// 启动服务器
app.listen(port, () => {
    console.log(`后端服务器在 ${port} 端口上运行`)
})

CUKd0Ik7-55.png

npm start 运行,在浏览器输入 localhost:3000/ 查看运行结果。

CUKd0Ik7-56.png

CUKd0Ik7-57.png

如果运行成功把 app.js 中的 app.get('/', (req, res) 示例函数删除,正常的路由写法将在下面讨论。

编写路由汇总文件

src/routes/index.js 中导入 express.Router() 创建 router 路由实例,然后导出路由实例。这个文件将用于汇总所有路由。

import express from 'express'

const router = express.Router()

export default router

CUKd0Ik7-58.png

src/app.js 中引入导出的 router 路由实例。

import express from 'express'
import cors from 'cors'
import routes from './routes/index.js'

const app = express()
const port = 3000

// 允许跨域请求
app.use(cors({
    origin: 'http://localhost:5173',  // 允许的前端地址
}))

// 解析前端发送的 JSON 数据
app.use(express.json())

// 挂载路由
app.use(routes)

// 启动服务器
app.listen(port, () => {
    console.log(`后端服务器在 ${port} 端口上运行`)
})

CUKd0Ik7-59.png

接口配置示例

下面写一个简单的接口为例,在系统接口中,访问 /helloworld 将输出 "Hello World !"。之后的接口也差不多是这个创建流程。

编写服务层,创建 src/services/systemService.js,添加 getHelloMessage() 函数获取数据 "Hello World !"。

export const getHelloMessage = () => {
    return 'Hello World !'
}

CUKd0Ik7-60.png

编写控制层,创建 src/controllers/systemController.js,导入 getHelloMessage() 函数获取数据,创建 getHello() 函数处理请求。

import { getHelloMessage } from '../services/systemService.js'

export const getHello = (req, res) => {
  const message = getHelloMessage()
  res.send(message)
}

CUKd0Ik7-61.png

编写路由,创建 src/routes/systemRoutes.js,导入 getHello() 处理请求,导入 express.Router() 创建路由实例,定义 getHello() 的路由为 /helloworld

import express from 'express'
import { getHello } from '../controllers/systemController.js'

const router = express.Router()

router.get('/helloworld', getHello)

export default router

CUKd0Ik7-62.png

最后在 src/routes/index.js 中汇总。注意,router.use() 里面的路径代表在原路由路径的前面再添加了一层路径,比如要访问 systemRoutes 中的路由,还需要在其路径前加 /system,访问 systemRoutes 中的 /helloworld 路由,完整路径就是 /system/helloworld

import express from 'express'
import systemRoutes from './systemRoutes.js'

const router = express.Router()

router.use('/system', systemRoutes)

export default router

CUKd0Ik7-63.png

使用 npm start 重新运行(一定要注意重新启动,它不会自动更新的!很容易将写 Vue 的习惯带进来,以为和 npm run dev 后一样是实时的,但那其实是 Vue 官方模板给你配置过的。记得我上个项目就因为这个问题排查了好久😭)。访问 localhost:3000/system/helloworld 查看是否成功。

CUKd0Ik7-64.png

3、API 开发工具 Hoppscotch 安装

如果后面接口多了起来,每一次都要在浏览器里面输入接口地址是一件很麻烦的事。而且后面在开发 QQ 机器人的时候还需要调用 QQ 官方提供的接口测试,浏览器很多时候难以满足很多要求,这时候就需要一个 API 开发工具。

传统的的开发工具一般是 Postman,不过没有中文,虽然 API 开发工具这种东西就是火星文也能用🛸。但我逛 Github 的时候发现了一个界面非常好看还支持中文的 API 开发工具——Hoppscotch。

API 开发工具这种东西就是起到一个保存常用 API 地址,和发送请求时能方便的带上一些参数(所以我说就是火星文也看得懂)的作用,所以当然选好看的😇。

这是 Hoppscotch 的官网地址:Hoppscotch • Make better APIs

安装后在设置里面就能调中文,登不登录无所谓,界面就是下面这个样子。和浏览器的收藏夹类似,点击文件夹图标创建文件夹,文件图标创建请求,在请求里保存请求 API 地址,这应该一目了然吧🥳。

CUKd0Ik7-65.png

4、使用 Axios 调用后端接口

后端开发的 API 接口需要被前端调用,这里使用 Axios 调用后端接口。

安装 Axios

Axios 是一个基于 promise 的网络请求库,可以用于浏览器和 Node.js,官网地址:Axios

根据官网中的文档创建 Axios 实例。

打开前端项目(这个是前端用于调用后端接口的,不要把它安装在后端了🤓),使用 npm install axios 安装。

创建 src/utils/request.js,参考官方文档的 Axios 实例 | Axios Docs拦截器 | Axios Docs 创建 Axios 实例并导出。

import axios from 'axios';

const instance = axios.create({
    baseURL: 'http://localhost:3000/',
    timeout: 10000,
});

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
    // 在发送请求之前做些什么
    return config;
}, function (error) {
    // 对请求错误做些什么
    return Promise.reject(error);
});

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response;
}, function (error) {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error);
});

export default instance

CUKd0Ik7-66.png

然后创建 src/api/ 用于存放 API 接口。

接口调用示例

这里让前端首页的 Mouse AI 这一栏调用之前的示例接口 localhost:3000/system/helloworld 显示 "Hello World !"。之后的接口调用也是这样。

CUKd0Ik7-67.png

创建 src/api/systemApi.js 用于存放系统接口。导入创建的 Axios 实例,调用 localhost:3000/system/helloworld 接口,封装为 getHello() 函数导出。

import instance from '@/utils/request.js'

// 获取 "Hello World !"
export const getHello = async () => {
    const res = await instance.get('/system/helloworld')
    return res.data
}

CUKd0Ik7-68.png

src/views/layout/Home.vue 导入 getHello() 函数,在 onMounted() 函数中执行替换。

import {onMounted, ref} from 'vue'

import {getHello} from '@/api/systemApi.js'

const systemInfo = ref([
    {label: 'Mouse AI', value: 'v1.0.0'},
    {label: '操作系统', value: 'Ubuntu 22.04'},
    {label: 'node.js 版本', value: '20.18.0'},
])

const systemLoad = ref([
    {label: 'CPU', value: 56, total: 100},
    {label: '内存', value: 68, total: 128},
    {label: '存储', value: 44, total: 512}
])

const llmStats = ref([
    {label: '请求总数', value: '12,843'},
    {label: '成功率', value: '98.4%'},
    {label: 'Token 消耗', value: '1.2M'},
])

onMounted(async () => {
    try {
        systemInfo.value[0].value = await getHello()
    } catch (err) {
        console.error('获取问候信息失败:', err)
    }
})

CUKd0Ik7-69.png

打开前端网页查看效果。

CUKd0Ik7-70.png

用完记得把这个示例接口删掉🫡。

5、编写首页系统相关接口

首页主要是有关系统的一些信息,Node.js 自带 os 模块,可以参考第三方汉化的 Node.js 文档:os 操作系统 | Node.js v24 文档

编写服务层

新增两个函数 getCpuCoreUsage()getSystemInfo()

  • getCpuCoreUsage() 函数,由于获取 CPU 的运行状态。

    /**
     * 获取每个 CPU 核心的使用率(百分比)。
     *
     * @async
     * @function getCpuCoreUsage
     * @returns {Promise<Object[]>} 各核心使用率数组。
     * @example
     * await getCpuCoreUsage()
     * // => [
     * //   { core: 0, usage: 23.47 },
     * //   { core: 1, usage: 0.00 },
     * //   { core: 2, usage: 18.33 },
     * //   { core: 3, usage: 25.60 }
     * // ]
     */
    export const getCpuCoreUsage = async () => {
        const start = os.cpus()
        await new Promise(resolve => setTimeout(resolve, 100))
        const end = os.cpus()
    
        return start.map((cpu, i) => {
            const startTimes = cpu.times
            const endTimes = end[i].times
    
            const idleDiff = endTimes.idle - startTimes.idle
            const totalDiff = Object.keys(endTimes)
                .map(k => endTimes[k] - startTimes[k])
                .reduce((a, b) => a + b, 0)
    
            const usage = 1 - idleDiff / totalDiff
            return { core: i, usage: Number((usage * 100).toFixed(2)) }
        })
    }
    

    使用 os 获取模块获取 100ms 内 CPU 开始和结束的信息,计算差值,得出 CPU 的利用率。

    await new Promise(resolve => setTimeout(resolve, 100)):等待 100ms。(如果不清楚 Promiseasync/await 的区别,可以看我写的😋:JavaScript 异步编程:理解 Promise 与 async/await,让异步代码更像同步代码 - 滕王阁

    key():获取一个对象的所有键(key),并以数组形式返回。

    map():用来对数组中的每个元素执行某个操作,并返回一个新的数组

    reduce():把数组中的所有元素归约(reduce)为单个值

    idleDiff:空闲时间差。

    totalDiff:所有时间类型(user、sys、idle、irq 等)的差值总和,也就是这 100ms 内 CPU 总运行时间。

    使用率公式:

    \text{usage} = 1 - \frac{\text{idleDiff}}{\text{totalDiff}}

    CPU 忙碌时间比例 = (总时间 - 空闲时间) / 总时间

  • getSystemInfo():函数,返回当前系统信息。

    /**
     * 获取系统信息概览。
     *
     * 包含版本号、平台、Node 版本、CPU 使用率、内存与磁盘信息。
     *
     * @async
     * @function getSystemInfo
     * @returns {Promise<Object>} 系统信息对象。
     * @example
     * await getSystemInfo()
     * // => {
     * //   version: "1.0.0",
     * //   platform: "win32",
     * //   nodeVersion: "v22.19.0",
     * //   cpuName: "Intel(R) Core(TM) i7-12700H",
     * //   cpuUsagePerCore: [...],
     * //   memory: {
     * //     totalGB: 31.72,
     * //     freeGB: 17.79
     * //   },
     * //   storage: {
     * //       filesystem: "Local Fixed Disk",
     * //       mounted: "C:",
     * //       totalGB: 851.74,
     * //       usedGB: 362.39,
     * //       availableGB: 489.35,
     * //       capacity: "43%"
     * //   }
     * // }
     */
    export const getSystemInfo = async () => {
        // 1. 获取项目版本号
        let version = 'unknown'
        try {
            const pkgPath = path.resolve(process.cwd(), 'package.json')
            const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
            version = pkg.version || 'unknown'
        } catch {}
    
        // 2. CPU、内存、磁盘信息
    
        // 获取 CPU 信息
        const cpuUsagePerCore = await getCpuCoreUsage()
        const cpuName = os.cpus()[0]?.model || 'unknown'
    
        // 获取内存信息,单位改为 GB
        const totalGB = Number((os.totalmem() / 1024 ** 3).toFixed(2))
        const freeGB = Number((os.freemem() / 1024 ** 3).toFixed(2))
    
        // 获取项目运行所在分区磁盘信息
        let storage = null
        try {
            const disks = getDiskInfoSync()
            const cwd = process.cwd()
            const root = path.parse(cwd).root // Windows: 'C:\\' Linux: '/'
    
            // 仅获取项目运行所在分区
            let currentDisk = null
            if (process.platform === 'win32') {
                const drive = root.slice(0, 2).toUpperCase()
                currentDisk = disks.find(d => (d.mounted || '').toUpperCase().startsWith(drive))
            } else {
                currentDisk = disks.find(d => cwd.startsWith(d.mounted))
            }
    
            if (currentDisk) {
                storage = {
                    filesystem: currentDisk.filesystem,
                    mounted: currentDisk.mounted,
                    totalGB: Number((currentDisk.blocks / 1024 ** 3).toFixed(2)),
                    usedGB: Number((currentDisk.used / 1024 ** 3).toFixed(2)),
                    availableGB: Number((currentDisk.available / 1024 ** 3).toFixed(2)),
                    capacity: currentDisk.capacity
                }
            }
        } catch (err) {
            console.error('磁盘信息获取失败:', err)
        }
    
        // 3. 返回系统信息
        return {
            version,
            platform: os.platform(),
            nodeVersion: process.version,
            cpuName,
            cpuUsagePerCore,
            memory: { totalGB, freeGB },
            storage
        }
    }
    

    流程代码中的注释已经很清晰了。

    path.resolve():生成绝对路径的方法。它会根据传入的相对路径拼出完整路径。

    process.cwd():当前 Node.js 进程的工作目录,即你运行脚本的地方。

    fs.readFileSync():同步读取文件内容。

    JSON.parse():将 JSON 字符串转换为 JSON 对象。

    getDiskInfoSync() 函数为第三方软件包 node-disk-info 中的函数,使用前要先 npm install node-disk-info 安装。

    把字节(bytes)转换为GB(千兆字节) 的计算公式:

    1\text{GB} = 1024^3\ \text{bytes}

    Number.toFixed():数字格式化为指定小数位数的字符串。

  • src/services/systemService.js 完整代码:

    import os from 'os'
    import fs from 'fs'
    import path from 'path'
    import { getDiskInfoSync } from 'node-disk-info'
    
    /**
     * 返回问候语。
     *
     * @function getHelloMessage
     * @returns {string} "Hello World !"
     * @example
     * getHelloMessage()
     * // => "Hello World !"
     */
    export const getHelloMessage = () => 'Hello World !'
    
    /**
     * 获取每个 CPU 核心的使用率(百分比)。
     *
     * @async
     * @function getCpuCoreUsage
     * @returns {Promise<Object[]>} 各核心使用率数组。
     * @example
     * await getCpuCoreUsage()
     * // => [
     * //   { core: 0, usage: 23.47 },
     * //   { core: 1, usage: 0.00 },
     * //   { core: 2, usage: 18.33 },
     * //   { core: 3, usage: 25.60 }
     * // ]
     */
    export const getCpuCoreUsage = async () => {
        const start = os.cpus()
        await new Promise(resolve => setTimeout(resolve, 100))
        const end = os.cpus()
    
        return start.map((cpu, i) => {
            const startTimes = cpu.times
            const endTimes = end[i].times
    
            const idleDiff = endTimes.idle - startTimes.idle
            const totalDiff = Object.keys(endTimes)
                .map(k => endTimes[k] - startTimes[k])
                .reduce((a, b) => a + b, 0)
    
            const usage = 1 - idleDiff / totalDiff
            return { core: i, usage: Number((usage * 100).toFixed(2)) }
        })
    }
    
    /**
     * 获取系统信息概览。
     *
     * 包含版本号、平台、Node 版本、CPU 使用率、内存与磁盘信息。
     *
     * @async
     * @function getSystemInfo
     * @returns {Promise<Object>} 系统信息对象。
     * @example
     * await getSystemInfo()
     * // => {
     * //   version: "1.0.0",
     * //   platform: "win32",
     * //   nodeVersion: "v22.19.0",
     * //   cpuName: "Intel(R) Core(TM) i7-12700H",
     * //   cpuUsagePerCore: [...],
     * //   memory: {
     * //     totalGB: 31.72,
     * //     freeGB: 17.79
     * //   },
     * //   storage: {
     * //       filesystem: "Local Fixed Disk",
     * //       mounted: "C:",
     * //       totalGB: 851.74,
     * //       usedGB: 362.39,
     * //       availableGB: 489.35,
     * //       capacity: "43%"
     * //   }
     * // }
     */
    export const getSystemInfo = async () => {
        // 1. 获取项目版本号
        let version = 'unknown'
        try {
            const pkgPath = path.resolve(process.cwd(), 'package.json')
            const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
            version = pkg.version || 'unknown'
        } catch {}
    
        // 2. CPU、内存、磁盘信息
    
        // 获取 CPU 信息
        const cpuUsagePerCore = await getCpuCoreUsage()
        const cpuName = os.cpus()[0]?.model || 'unknown'
    
        // 获取内存信息,单位改为 GB
        const totalGB = Number((os.totalmem() / 1024 ** 3).toFixed(2))
        const freeGB = Number((os.freemem() / 1024 ** 3).toFixed(2))
    
        // 获取项目运行所在分区磁盘信息
        let storage = null
        try {
            const disks = getDiskInfoSync()
            const cwd = process.cwd()
            const root = path.parse(cwd).root // Windows: 'C:\\' Linux: '/'
    
            // 仅获取项目运行所在分区
            let currentDisk = null
            if (process.platform === 'win32') {
                const drive = root.slice(0, 2).toUpperCase()
                currentDisk = disks.find(d => (d.mounted || '').toUpperCase().startsWith(drive))
            } else {
                currentDisk = disks.find(d => cwd.startsWith(d.mounted))
            }
    
            if (currentDisk) {
                storage = {
                    filesystem: currentDisk.filesystem,
                    mounted: currentDisk.mounted,
                    totalGB: Number((currentDisk.blocks / 1024 ** 3).toFixed(2)),
                    usedGB: Number((currentDisk.used / 1024 ** 3).toFixed(2)),
                    availableGB: Number((currentDisk.available / 1024 ** 3).toFixed(2)),
                    capacity: currentDisk.capacity
                }
            }
        } catch (err) {
            console.error('磁盘信息获取失败:', err)
        }
    
        // 3. 返回系统信息
        return {
            version,
            platform: os.platform(),
            nodeVersion: process.version,
            cpuName,
            cpuUsagePerCore,
            memory: { totalGB, freeGB },
            storage
        }
    }
    

编写控制层

新增 getSystemStatus() 函数。

  • src/controllers/systemController.js 完整代码:

    import { getHelloMessage, getSystemInfo } from '../services/systemService.js'
    
    // 返回欢迎消息
    export const getHello = (req, res) => {
        const message = getHelloMessage()
        res.send(message)
    }
    
    // 返回系统信息
    export const getSystemStatus = async (req, res) => {
        const systemInfo = await getSystemInfo()
        res.json(systemInfo)
    }
    

编写路由

新增 /system/info 路由。

  • src/routes/systemRoutes.js 完整代码:

    import express from 'express'
    import { getHello, getSystemStatus } from '../controllers/systemController.js'
    
    const router = express.Router()
    
    // 测试接口
    router.get('/helloworld', getHello)
    
    // 系统信息接口
    router.get('/info', getSystemStatus)
    
    
    export default router
    

接口测试

Hoppscotch GET 方法访问 http://localhost:3000/system/info 返回:

{
  "version": "1.0.0",
  "platform": "win32",
  "nodeVersion": "v22.19.0",
  "cpuName": "13th Gen Intel(R) Core(TM) i9-13900HX",
  "cpuUsagePerCore": [
    {
      "core": 0,
      "usage": 0
    },
    {
      "core": 1,
      "usage": 0
    },
    {
      "core": 2,
      "usage": 0
    },
    {
      "core": 3,
      "usage": 0
    },
    {
      "core": 4,
      "usage": 0
    },
    {
      "core": 5,
      "usage": 37.1
    },
    {
      "core": 6,
      "usage": 75
    },
    {
      "core": 7,
      "usage": 0
    },
    {
      "core": 8,
      "usage": 0
    },
    {
      "core": 9,
      "usage": 62.4
    },
    {
      "core": 10,
      "usage": 77.46
    },
    {
      "core": 11,
      "usage": 0
    },
    {
      "core": 12,
      "usage": 12.7
    },
    {
      "core": 13,
      "usage": 0
    },
    {
      "core": 14,
      "usage": 0
    },
    {
      "core": 15,
      "usage": 0
    },
    {
      "core": 16,
      "usage": 0
    },
    {
      "core": 17,
      "usage": 0
    },
    {
      "core": 18,
      "usage": 0
    },
    {
      "core": 19,
      "usage": 0
    },
    {
      "core": 20,
      "usage": 0
    },
    {
      "core": 21,
      "usage": 0
    },
    {
      "core": 22,
      "usage": 0
    },
    {
      "core": 23,
      "usage": 0
    },
    {
      "core": 24,
      "usage": 0
    },
    {
      "core": 25,
      "usage": 0
    },
    {
      "core": 26,
      "usage": 0
    },
    {
      "core": 27,
      "usage": 0
    },
    {
      "core": 28,
      "usage": 0
    },
    {
      "core": 29,
      "usage": 0
    },
    {
      "core": 30,
      "usage": 0
    },
    {
      "core": 31,
      "usage": 0
    }
  ],
  "memory": {
    "totalGB": 31.7,
    "freeGB": 17.26
  },
  "storage": {
    "filesystem": "Local Fixed Disk",
    "mounted": "D:",
    "totalGB": 100,
    "usedGB": 34.66,
    "availableGB": 65.34,
    "capacity": "35%"
  }
}

原来我电脑 CPU 这么多核吗😲。

前端调用


评论