3. 基础权限框架(鹿线开源)
第一章 Electron基础
第一节 electron开发模式
1.1.1 electron本身开发模式
1.1 npm init -y
***生成一个package.json
1.2 安装electron
npm install electron --save-dev
***最好采用国内镜像
1.3 项目的根目录新建一个index.js
const { app, BrowserWindow } = require('electron')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
1.4 启动项目==》设置命令 [package.json]
"scripts": {
"start": "electron .",
"test": "echo \"Error: no test specified\" && exit 1"
},
1.5 启动项目
npm run start
1.6 项目根目录新建一个index.html
1.1.2 我们的开发模式:electron + vite + vue3
2.1 参考网址: https://evite.netlify.app/
2.2 安装步骤
npx degit alex8088/electron-vite-boilerplate electron-app
cd electron-app
npm install
npm run dev
1.1.3 安装路由
3.1 安装路由:npm install vue-router -S
3.2 新建:router/index.js
3.3router/index.js写入内容
import { createRouter, createWebHashHistory } from "vue-router";
export default createRouter({
history: createWebHashHistory(),//hash模式
routes:[{ path: "/", component: Login }]//路由配置规则数组
})
3.4 main.ts中use一下router
use(router)
第二节 目录结构和安装pinia
1.2.1 electron-app项目,目录结构说明
build ==》打包操作,例如:打包后的应用图标,.env环境变量的配置等等
resources ==〉资料目录,例如:放入图片,有点类似于vue-cli中的publick目录
src
|main ==>操作主进程内容,例如:创建窗口等
|preload ==>预加载,本身渲染进程是不可以使用electron等api的,通过preload预加载就可以使用了。
|renderer ==>操作渲染进程,就是应用中的内容。
electron.vite.config.ts ==》这个和vue中的vite.config.js || vue.config.js类似
***其他目录文件随着课程的更新,会不断给大家讲述,包括打包项目时我们还会新增很多文件。
1.2.2 下载pinia
2.1 官网:https://pinia.web3doc.top/
2.2 下载:npm install pinia -S
2.3 main.ts引入
import { createPinia } from 'pinia'
app.use(createPinia())
2.4 新建目录:store/index.js
import { defineStore } from 'pinia'
export const useStore = defineStore('storeId', {
state: () => {
return {
counter: 0,
}
},
getters:{},
actions:{}
})
- 关于pinia的二次封装和持久化存储,后续会讲述
第三节 electron设置代理和api解耦
1.3.1 electron.vite.config.ts配置
export default defineConfig({
main: {
...
},
preload: {
...
},
renderer: {
resolve: {
alias: {
...
}
},
server:{
"proxy":{
"/api":{
target:'http://uat.crm.xuexiluxian.cn',
changeOrigin:true,
rewrite: path => path.replace(/^\/api/,'')
}
}
},
plugins: [
...
]
}
})
1.3.2 封装request
npm i axios -S
import axios from 'axios'
//1. 创建axios对象
const instance = axios.create({
baseURL: '/api',
});
//2. 请求拦截器
instance.interceptors.request.use(function(config) {
return config;
}, function(error) {
return Promise.reject(error);
});
//3. 响应拦截器
instance.interceptors.response.use(function(response) {
return response;
}, function(error) {
return Promise.reject(error);
});
export default instance;
STEP2: 配置utils的短路经
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
preload: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@store': resolve('src/renderer/src/store'),
'@utils': resolve('src/renderer/src/utils') // here
}
},
server: {// 开发环境将以 /api 开头的请求转发到target这个地址
...
},
plugins: [vue()]
}
})
1.3.2 打开调试
mainWindow.webContents.openDevTools();
第四节 渲染进程向主进程通信
1.4.1 渲染进程 向 主进程通信
- 渲染进程:
electron.ipcRenderer.invoke('名称',{ 传值 } )
- 主进程:
import { app, shell, BrowserWindow , ipcMain } from 'electron'
ipcMain.handle('名称',(event,渲染进程向主进程传递的参数)=>{
})
接口地址:http://doc.xuexiluxian.cn/
账号:testapi
密码:123456
后台管理系统用户名 + 密码
admin
abc123456
第二章 基础配置
第一节 路由配置
2.1.1 安装Electron
官网: https://evite.netlify.app/ (拖到最下面就是这个)
npx degit alex8088/electron-vite-boilerplate electron-app
cd electron-app
npm install
npm run dev
- vue版本:vue3
- 构建工具:vite
- 框架类型:Electron
- JS语法:TypeScript
2.1.2 安装路由
vue默认都是, 在 \electron-app\src\renderer\src\这个src目录下进行的
STEP1: 安装vue-router: npm install vue-router -S
STEP2: 新建router/index.ts
import { createRouter, createWebHashHistory } from "vue-router";
const router = createRouter({
history: createWebHashHistory(),//hash模式
routes: [//路由配置规则数组
{ path: "/", name: "首页", component: () => import("../views/Home.vue") },
{ path: "/about", name: "关于", component: () => import("../views/About.vue") },
],
});
// 1.前置路由守卫
router.beforeEach((to, from) => {
console.log("前置路由守卫", to, from);
return true; // 放行 for test
});
// 2.后置路由守卫
router.afterEach((to, from) => {
console.log("后置路由守卫", to, from);
});
export default router;
- main.ts中use一下router
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'; // 路由
const app = createApp(App);
app.use(router); // 1.使用路由
app.mount('#app');
在 http://localhost:5173/#/ 和 http://localhost:5173/#/about 进行跳转测试

- App.vue
<template>
<div>
<router-view></router-view>
</div>
</template>
<script>
import router from './router';
export default {
}
</script>
<style scoped></style>
- views/About.vue
<template>
<div>
<h1>About</h1>
</div>
</template>
<script>
export default {
}
</script>
<style scoped></style>
- views/Home.vue
<template>
<div>
<h1>Home</h1>
</div>
</template>
<script>
export default {
}
</script>
<style scoped></style>
2.1.3 路由二次封装
- 疑问: 为什么路由二次封装?
- 解答: router文件中在写项目中包含:路由表、导航守卫等等内容,假设路由表特别多 或 导航守卫内容特别多,代码则很难维护和阅读,所以二次封装路由是为了方便后期维护和管理。
- 具体怎么做: ①把路由表分一个文件
router/routes②把路由守卫分一个文件router/guards
主路由
import { createRouter, createWebHashHistory } from "vue-router";
import { AppRoutes } from './routes'
import { beforeEach, afterEach } from './guards'
const router = createRouter({
history: createWebHashHistory(),//hash模式
routes: AppRoutes, // 路由表
});
//注册路由守卫
router.beforeEach(beforeEach);
router.afterEach(afterEach);
export default router;
路由表
export const AppRoutes = [//路由配置规则数组
{ path: "/", name: "首页", component: () => import("../views/Home.vue") },
{ path: "/about", name: "关于", component: () => import("../views/About.vue") },
]
导航守卫
// 1.前置路由守卫
export const beforeEach = async (to, from) => {
console.log("前置路由守卫", to, from);
return true; // 放行 for test
};
// 2.后置路由守卫
export const afterEach = (to, from) => {
console.log("后置路由守卫", to, from);
};
第二节 安装store(pinia) 和 持久化存储
2.2.1 安装pinia

STEP1: npm install pinia -S
STEP2: main.ts中引入
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'; // 路由
import { createPinia } from 'pinia';
const app = createApp(App);
app.use(router); // 1.使用路由
app.use(createPinia()); // 2.使用pinia状态管理
app.mount('#app');
- store/index.ts
import { defineStore } from 'pinia'
export const useStore = defineStore('storeId', {
state: () => {
return {
counter: 100,
}
},
getters: {},
actions: {}
})
- 去Home.vue中打印下counter, 看能否拿到
<template>
<div>
<h1>Home</h1>
<h1>{{ store.counter }}</h1> <!-- 测试pinia能否拿值 -->
</div>
</template>
<script setup>
import { useStore } from '../store'
const store = useStore(); //
</script>
<style scoped></style>
2.2.2 pinia的二次封装
- 问题: 如果”桶”太多了, 没有目录很难找
- 解决方法: index.ts不存值, 这个桶专门当做目录
![]() |
![]() |
|---|
import { defineStore } from 'pinia'
import { useUserStore } from './useUserStore'
// index.ts相当于目录, 方便后续管理多个store
export const useStore = defineStore('storeId', {
state: () => {
return {
user: useUserStore(), //用户桶
// ...后续还有什么桶, 都放这里
}
},
getters: {},
actions: {}
})
用户桶
import { defineStore } from 'pinia'
export const useUserStore = defineStore('userId', {
state: () => {
return {
name: 'Tlyer233',
token: "1234",
}
},
getters: {},
actions: {},
persist: { // pinia的插件--数据持久化存储
storage: localStorage,
// paths: ['name', 'token'] // 不写就是全部存储; 写的话就是只持久化指定字段
}
})
- useStore是目录, 一定要useStore()才拿到目录
<template>
<div>
<h1>Home</h1>
<h1>{{ userStore.name }}</h1> <!-- 拿用户桶里面的name -->
</div>
</template>
<script setup>
import { useStore } from '../store/index'
const userStore = useStore().user; // 获取用户桶
</script>
<style scoped></style>
2.2.3 pinia插件实现 持久化存储
- 一般的pinia:
userStore.token = 'abc'网页刷新后就变回去了, 无法持久化数据, 但是使用persistedstatepinia插件, 就可以实现在浏览器的localStoreage中存储数据
- 下载pinia插件:
下载安装:npm install pinia-plugin-persistedstate(pinia-plugin-persist已经过时) - mian.ts中use下
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App);
// 1.使用路由
import router from './router';
app.use(router);
// 2.使用pinia状态管理
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const store = createPinia()
store.use(piniaPluginPersistedstate) // 注册数据持久化插件
app.use(store);
app.mount('#app');
import { defineStore } from 'pinia'
export const useUserStore = defineStore('userId', {
state: () => {
return {
name: 'Tlyer233',
token: "1234",
}
},
getters: {},
actions: {},
persist: { // pinia的插件--数据持久化存储
storage: localStorage,
// paths: ['name', 'token'] // 不写就是全部存储; 写的话就是只持久化指定字段
}
})
<template>
<div>
<h1>Home</h1>
<h1>{{ userStore.name }}</h1> <!-- 测试pinia能否拿值 -->
<button @click="userStore.token = 'abc'">Change Token</button>
<p>{{ userStore.token }}</p>
</div>
</template>
<script setup>
import { useStore } from '../store/index'
const userStore = useStore().user; // 测试pinia持久化存储
</script>
<style scoped></style>
第三节 路径别名
2.3.1 路径别名(vite配置)
![]() |
![]() |
|---|
- 说明: 这两个文件都是根路径下的(下面如果没说是根, 默认是src/renderer/src起始)
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
preload: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
renderer: {
resolve: {
// vite配置1:路径别名
alias: {
'@renderer': resolve('src/renderer/src'),
'@store': resolve('src/renderer/src/store'),
'@views': resolve('src/renderer/src/views'),
'@router': resolve('src/renderer/src/router'),
'@components': resolve('src/renderer/src/components'),
}
},
plugins: [vue()]
}
})
这个不配置的话, ts不认识, ts没有提示, 会报错(虽然可能可以运行)
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.vue",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": { // 路径别名
"@renderer/*": [
"src/renderer/src/*"
],
"@store/*": [
"src/renderer/src/store/*"
],
"@views/*": [
"src/renderer/src/views/*"
],
"@router/*": [
"src/renderer/src/router/*"
],
"@components/*": [
"src/renderer/src/components/*"
]
}
}
}
- 然后把之前, router那些路径改下….
import { defineStore } from 'pinia'
import { useUserStore } from '@store/useUserStore'
// index.ts相当于目录, 方便后续管理多个store
export const useStore = defineStore('storeId', {
state: () => {
return {
user: useUserStore(), //用户桶
}
},
getters: {},
actions: {}
})
第四节 配置代理 和 请求二次封装
2.4.1 配置代理
- 原因: ①解决跨域的问题 ②所有以 /api 开头的请求, 都会被代理到 target 地址
- 文件: 根目录下:
electron.vite.config.ts
...
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
preload: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
renderer: {
resolve: {
// vite配置1:路径别名
alias: {
...
}
},
// vite配置2:proxy代理: 所有以 /api 开头的请求, 都会被代理到 target 地址
server: {
"proxy": {
"/api": {
// target: 'http://uat.crm.xuexiluxian.cn', // 线上测试接口
target: 'localhost:8080', // 本地测试接口
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
},
plugins: [vue()]
}
})
2.4.2 axios二次封装
- 下载:
npm install axios - 新建文件:
utils/request.ts - 提示: 配置”路径别名@utils”
import axios from 'axios';
const request = axios.create({
baseURL: '/api'
});
// 添加请求拦截器
request.interceptors.request.use(function (config) {
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error);
});
// 添加响应拦截器
request.interceptors.response.use(function (response) {
return response.data; // 直接返回数据
}, function (error) {
return Promise.reject(error);
});
export default request;
2.4.3 api文件管理
- 新建文件:
api/login.ts - 提示: 配置”路径别名@api”
import request from "@utils/request";
//用户登录
export const loginByJson = ( data )=>{
return request({
url:'/u/loginByJson',
method:'post',
data
})
}
第五节 Element Plus配置
2.5.1 安装Element Plus
- 官网: https://element-plus.org/zh-CN/guide/quickstart
- 下载:
npm install element-plus --save - 按需引入:
npm install -D unplugin-vue-components unplugin-auto-import - 修改文件: 根目录下electron.vite.config.ts
//element
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
}
},
server:{
"proxy":{
...
}
},
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
})
]
}
})
第三章 开始项目
七、登录页布局
7.1 左侧布局
<template>
<div class="login_bg">
<!--左边-->
<div class="login_adv">
<div class="login_adv__title">
<h2>小鹿线</h2>
<h4>客户关系管理系统</h4>
<p>让业务在线更高效,加速企业数字化升级。</p>
</div>
<div class="login_adv__mask"></div>
<div class="login_adv__imgage">
<img src="../assets/images/data.png" width="100%">
</div>
<div class="login_adv__bottom">
© 小鹿线客户管理系统 1.0.11
</div>
</div>
</div>
</template>
<style scoped>
.login_bg {
width: 100vw;
height: 100vh;
background: #fff;
display: flex;
}
.login_adv {
background-image: url('../assets/images/auth_banner.jpg');
width: 40%;
position: relative;
}
.login_adv__title {
color: #fff;
padding: 40px;
position: absolute;
top: 0px;
left: 0px;
right: 0px;
z-index: 2;
}
.login_adv__title h2 {
font-size: 40px;
}
.login_adv__title h4 {
font-size: 18px;
margin-top: 10px;
font-weight: normal;
}
.login_adv__title p {
font-size: 14px;
margin-top: 10px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.6);
}
.login_adv__title div {
margin-top: 10px;
display: flex;
align-items: center;
}
.login_adv__imgage{
position: absolute;
left: 0px;
right: 0px;
bottom: 80px;
padding: 40px;
z-index: 3;
}
.login_adv__imgage img{
width: 100%;
}
.login_adv__bottom {
position: absolute;
left: 0px;
right: 0px;
bottom: 0px;
color: #fff;
padding: 0 40px 40px 40px;
background-image: linear-gradient(transparent, #000);
z-index: 3;
}
.login_adv__mask {
position: absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
</style>
7.2 右侧布局
<template>
<div class="login">
<!--左侧-->
<div class="login_adv">
<div class="login_adv_title">
<h2>小鹿线</h2>
<h4>客户关系管理系统</h4>
<p>让业务在线更高效,加速企业数字化升级。</p>
</div>
<div class="login_adv_mask"></div>
<div class="login_adv_imgage">
<img src="../assets/images/data.png" width="100%">
</div>
<div class="login_adv_bottom">
© 小鹿线客户管理系统 1.0.11
</div>
</div>
<!--右侧-->
<div class="login-main">
<div class="login-form">
<div class="login-header">
<div class="login-img">
<img src="../assets/images/logo.png" alt="">
<label>小鹿线客户管理系统</label>
</div>
</div>
<el-tabs>
<el-tab-pane label="账号登录" lazy>User</el-tab-pane>
<el-tab-pane label="手机号登录" lazy>Config</el-tab-pane>
</el-tabs>
<template v-if='true'>
<el-divider>其他登录方式</el-divider>
<div class="login-oauth">
<!--微信按钮-->
<el-button type="success" circle size="large">
<el-icon size="large">
<ChatDotRound />
</el-icon>
</el-button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.login{
width:100vw;
height: 100vh;
background-color: #fff;
display: flex;
}
.login_adv{
background: url('../assets/images/auth_banner.jpg') no-repeat;
width:40%;
position: relative;
}
.login_adv_title{
position: absolute;
top:0;
left:0;
right:0;
z-index: 2;
padding:40px;
color:#fff;
}
.login_adv_title h2{
font-size: 40px;
}
.login_adv_title h4{
font-size: 18px;
margin-top: 10px;
}
.login_adv_title p{
font-size: 14px;
margin-top: 10px;
line-height: 1.8;
color: rgb(255,255,255,.6);
}
.login_adv_imgage{
position: absolute;
left:0px;
right:0px;
bottom:80px;
padding:40px;
z-index: 3;
}
.login_adv_imgage img{
width: 100%;
height: 100%;
}
.login_adv_bottom{
position: absolute;
left:0px;
right:0px;
bottom:0px;
color:#fff;
padding:0 40px 40px 40px;
}
.login_adv_mask{
position: absolute;
left:0px;
top:0px;
right:0px;
bottom:0px;
background:rgba(0, 0, 0,.5);
z-index: 1;
}
.login-main{
flex:1;
display: flex;
overflow: auto;
}
.login-form{
width:400px;
margin: auto;
padding:80px 0 0 0;
}
.login-header{
margin-bottom: 40px;
}
.login-header .login-img{
display: flex;
justify-content: center;
align-items: center;
}
.login-header .login-img img{
width:40px;
height: 40px;
vertical-align: bottom;
margin-right:10px;
}
.login-header .login-img label{
font-size:26px;
font-weight: bold;
}
.login-oauth{
display: flex;
justify-content: space-around;
}
</style>
7.3 下载引入icon
安装
npm install @element-plus/icons-vue
main.ts引入
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
八、账号登录
8.1 布局 + 逻辑
<template>
<el-form ref="ruleFormRef" :model="form" :rules="rules" label-width="0" size="large">
<el-form-item prop="username">
<el-input v-model="form.username" prefix-icon="user" clearable placeholder="请输入">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" prefix-icon="lock" clearable show-password placeholder="请输入"></el-input>
</el-form-item>
<el-form-item>
<div class="boxCode">
<el-input v-model="form.captcha" prefix-icon="CircleCheck" clearable placeholder="请输入验证码" ></el-input>
<el-image :src="captchaUrl" @click="getImage" class="code"></el-image>
</div>
</el-form-item>
<div class="rememberMe">
<div>
<el-checkbox label="记住密码" />
</div>
<div>
<router-link to="/reset_password">忘记密码?</router-link>
</div>
</div>
<el-form-item>
<el-button type="primary" style="width: 100%;" round @click="login(ruleFormRef)">登录</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive , ref , onBeforeMount } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { captchaImage , loginByJson } from '@api/login'
import { Encrypt } from '@utils/aes'
import { RuleForm } from '../types/login'
let captchaUrl = ref<string>('');
const ruleFormRef = ref<FormInstance>()
const form = reactive<RuleForm>({
username: "",
password: "",
captcha: "",
key: ''
})
const rules =reactive<FormRules<RuleForm>>({
username: [
{required: true, message: '正确用户名', trigger: 'blur'}
],
password: [
{required: true, message: '正确密码', trigger: 'blur'}
]
})
let getImage = async ()=>{
const key = new Date().getTime().toString();
form.key = key;
const res = await captchaImage({ key })
let blob = new Blob([res],{type:'application/vnd.ms-excel'});
let imgUrl = URL.createObjectURL( blob );
captchaUrl.value = imgUrl;
}
onBeforeMount(()=>{
getImage();
})
const login = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid:boolean) => {
if (valid) {
let res = await loginByJson({
username:Encrypt(form.username),
password:Encrypt(form.password),
key:form.key,
captcha:form.captcha
});
if( res.code != "200" ){
return ElMessage.error(res.msg)
}
} else {
return ElMessage.warning('请填写正确内容')
}
return;
})
}
</script>
<style scoped>
.boxCode{
display: flex;
align-items: center;
width: 100%;
}
.code{
margin-left: 10px;
height: 40px;
width: 100px;
cursor: pointer;
}
.rememberMe{
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
</style>
8.2 解决的问题
1. 验证码报错的问题,需要注释掉index.html中的meta
2. element-plus的message样式不生效需要在main.ts加入:import 'element-plus/theme-chalk/index.css'
3. 加密使用:aes
4. 新建types文件,其内容全部为interface内容
九、手机号 验证码登录
9.1 布局
<template>
<el-form
ref="ruleFormRef"
:model="ruleForm"
:rules="rules"
label-width="0"
size="large"
>
<el-form-item prop="mobile">
<el-input v-model="ruleForm.mobile" prefix-icon="iphone" clearable placeholder="请输入手机号">
<template #prepend>+86</template>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<div class="login-msg-yzm">
<el-input v-model="ruleForm.captcha" prefix-icon="unlock" clearable placeholder="请输入验证码"></el-input>
<el-button :disabled="disabled">获取验证码<span v-if="disabled">({{ time }})</span></el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100%;" round :loading='isLogin'>登录</el-button>
</el-form-item>
<el-form-item>
<router-link to="">忘记密码?</router-link>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { PhoneRuleForm } from '@interface/login'
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<PhoneRuleForm>({
mobile: '',
captcha: ''
})
const rules = reactive<FormRules<PhoneRuleForm>>({
mobile: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
],
captcha: [
{ required: true, message: '请输入密码', trigger: 'blur' },
]
})
const time = ref<number>(60);
const disabled = ref<boolean>(false);
const isLogin = ref<boolean>(false);
</script>
<style scoped>
.login-msg-yzm{
display: flex;
width: 100%;
}
.login-msg-yzm .el-button{
margin-left:10px;
}
</style>
9.2 布局 + 逻辑
<template>
<el-form
ref="ruleFormRef"
:model="ruleForm"
:rules="rules"
label-width="0"
size="large"
>
<el-form-item prop="mobile">
<el-input v-model="ruleForm.mobile" prefix-icon="iphone" clearable placeholder="请输入手机号">
<template #prepend>+86</template>
</el-input>
</el-form-item>
<el-form-item prop="captcha">
<div class="login-msg-yzm">
<el-input v-model="ruleForm.captcha" prefix-icon="unlock" clearable placeholder="请输入验证码"></el-input>
<el-button @click="getCode" :disabled="disabled">获取验证码<span v-if="disabled">({{ time }})</span></el-button>
</div>
</el-form-item>
<el-form-item>
<el-button @click='login(ruleFormRef)' type="primary" style="width: 100%;" round :loading='isLogin'>登录</el-button>
</el-form-item>
<el-form-item>
<router-link to="">忘记密码?</router-link>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { PhoneRuleForm } from '@interface/login'
import { loginCaptcha , loginByMobile } from '@api/login'
import { Encrypt } from '@utils/aes'
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<PhoneRuleForm>({
mobile: '',
captcha: ''
})
const validatorTel = (rule: any, value: string, callback: any) => {
if( value === ''){
callback( new Error('请填写手机号') );
}else if( !/^1[3456789]\d{9}$/.test( value ) ){
callback( new Error('请填写正确手机号') );
}else{
callback();
}
}
const rules = reactive<FormRules<PhoneRuleForm>>({
mobile: [
{ validator:validatorTel, trigger: 'blur' },
],
captcha: [
{ required: true, message: '请输入密码', trigger: 'blur' },
]
})
const time = ref<number>(60);
const disabled = ref<boolean>(false);
const isLogin = ref<boolean>(false);
//获取验证码
const getCode = async ()=>{
let validate = await ruleFormRef.value?.validateField('mobile', () => null);
if( !validate ){
return ElMessage.error('请填写正确的手机号');
}
let res = await loginCaptcha({
mobile:Encrypt( ruleForm.mobile )
})
if( res.code != '200') return ElMessage.error(res.msg);
ElMessage.success('发送成功');
disabled.value = true;
time.value = 60;
let timer = setInterval(()=>{
time.value -= 1;
if( time.value < 1 ){
clearInterval( timer );
disabled.value = false;
time.value = 0;
}
},1000)
return ;
}
//登录
const login = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate(async (valid) => {
if (valid) {
let res = await loginByMobile({
mobile:Encrypt( ruleForm.mobile ),
captcha:Encrypt( ruleForm.captcha )
})
if( res.code !='200') return ElMessage.error(res.msg);
} else {
return ElMessage.warning('请填写正确内容');
}
return ;
})
}
</script>
<style scoped>
.login-msg-yzm{
display: flex;
width: 100%;
}
.login-msg-yzm .el-button{
margin-left:10px;
}
</style>
十、Electron窗口调整
10.1 【默认】有标题栏,有标题

10.2 有标题栏,无标题

***有系统自带按钮
主进程设置:
const mainWindow = new BrowserWindow({
...
titleBarStyle: 'hidden',//隐藏标题
titleBarOverlay: {
color: '#ffffff',
symbolColor: '#74b1be'
},
...
})
问题:窗口就无法拖拽了,解决方案为:渲染进程添加样式
<style>
div {
-webkit-app-region: drag;
}
</style>
10.3 无标题栏,无标题

主进程:
const mainWindow = new BrowserWindow({
...
frame: false,//无边框窗口
...
})
问题:窗口无法拖拽,解决方案:渲染进程通信主进程,采用拖拽的形式完成
<div class="button" @mousedown="mousedown"></div>
<script>
import { ref } from 'vue';
let isKeyDown = ref<boolean>(false);
let dinatesX = ref<number>(0);
let dinatesY = ref<number>(0);
const mousedown = ( event )=>{
isKeyDown.value = true;
dinatesX.value = event.x;
dinatesY.value = event.y;
document.onmousemove = (ev) => {
if(isKeyDown.value ){
const x = ev.screenX - dinatesX.value;
const y = ev.screenY - dinatesY.value;
//给主进程传入坐标
let data = {
appX:x,
appY:y
}
electron.ipcRenderer.invoke('custom-adsorption',data);
}
};
document.onmouseup = () => {
isKeyDown.value = false
};
}
</script>
主进程:
ipcMain.handle('custom-adsorption',(event,res) => {
let x = res.appX;
let y = res.appY;
mainWindow.setPosition( x , y )
})
十一、窗口按钮-关闭软件

11.1 布局
<div class="login-config">
<div class="login-config-btn">
<el-button icon="close" circle type="default"></el-button>
</div>
</div>
.login-config{
position: absolute;
top: 0;
right: 0;
width: 100%;
z-index: 9999;
}
.login-config-btn{
display: flex;
justify-content: space-between;
align-items: center;
width: 120px;
margin: 10px 10px 0 0;
float: right;
}
11.2 关闭窗口
close 方法用于关闭窗口,可以通过监听 close 事件来执行一些自定义操作,并有机会取消关闭操作。
destroy 方法用于彻底销毁一个窗口,不会触发 close 事件,并立即释放与窗口相关的所有资源。
11.3 关闭软件
app.quit() 方法用于退出整个 Electron 应用程序,可以通过监听 before-quit 事件来执行一些预处理操作。
app.exit() 方法用于立即终止整个 Electron 应用程序的进程,不会触发任何事件。
十二、窗口按钮-换肤
12.1 布局
<el-button circle @click='configDark'>
<el-icon v-if='dark'><Sunny /></el-icon>
<el-icon v-else><Moon /></el-icon>
</el-button>
12.2 添加scss文件
//App.vue
<style lang='scss'>
@import './assets/css/style.scss';
</style>
//style.scss
@import 'node_modules/element-plus/theme-chalk/src/dark/css-vars.scss';
*{margin: 0;padding:0;}
a{
text-decoration: none;
color:#333;
}
html.dark {
//变量
--el-text-color-primary: #d0d0d0;
--el-color-primary-dark-2: var(--el-color-primary-light-2) !important;
--el-color-primary-light-9: var(--el-color-primary-dark-8) !important;
--el-color-primary-light-8: var(--el-color-primary-dark-7) !important;
--el-color-primary-light-7: var(--el-color-primary-dark-6) !important;
--el-color-primary-light-5: var(--el-color-primary-dark-4) !important;
--el-color-primary-light-3: var(--el-color-primary-dark-3) !important;
//登录背景
.login {background: var(--el-bg-color);}
a{
color:#fff;
}
}
12.3 逻辑
const dark = ref<string | null>(localStorage.getItem('dark'));
const configDark = ()=>{
const element = document.querySelector('html') as HTMLElement | null;
if (element) {
if( element.className == 'dark' ){
element.className = '';
}else{
element.className = 'dark';
}
dark.value = element.className;
localStorage.setItem('dark', element.className);
}
}
12.4 App.vue初始化皮肤样式
<template>
<router-view></router-view>
</template>
<script setup lang="ts">
import { ref , onBeforeMount } from 'vue';
onBeforeMount(()=>{
const dark = ref<string | null>( localStorage.getItem('dark') );
const element = document.querySelector('html') as HTMLElement | null;
if( element ){
if( dark.value == 'dark' ){
element.className = 'dark';
}else{
element.className = '';
}
}
})
</script>
<style lang="scss">
@import './assets/css/style.scss';
</style>
12.5 顺便把Electron类型给了
//preload/index.d.ts
declare global {
interface Window {
electron: ElectronAPI
api: unknown
ipcRenderer: Electron.IpcRenderer
}
}
13、窗口按钮-国际化
13.1 布局
<el-dropdown trigger="click" @command="configLang">
<el-button circle>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" preserveAspectRatio="xMidYMid meet" viewBox="0 0 512 512"><path d="M478.33 433.6l-90-218a22 22 0 0 0-40.67 0l-90 218a22 22 0 1 0 40.67 16.79L316.66 406h102.67l18.33 44.39A22 22 0 0 0 458 464a22 22 0 0 0 20.32-30.4zM334.83 362L368 281.65L401.17 362z" fill="currentColor"></path><path d="M267.84 342.92a22 22 0 0 0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73c39.65-53.68 62.11-114.75 71.27-143.49H330a22 22 0 0 0 0-44H214V70a22 22 0 0 0-44 0v20H54a22 22 0 0 0 0 44h197.25c-9.52 26.95-27.05 69.5-53.79 108.36c-31.41-41.68-43.08-68.65-43.17-68.87a22 22 0 0 0-40.58 17c.58 1.38 14.55 34.23 52.86 83.93c.92 1.19 1.83 2.35 2.74 3.51c-39.24 44.35-77.74 71.86-93.85 80.74a22 22 0 1 0 21.07 38.63c2.16-1.18 48.6-26.89 101.63-85.59c22.52 24.08 38 35.44 38.93 36.1a22 22 0 0 0 30.75-4.9z" fill="currentColor"></path></svg>
</el-button>
<template #dropdown >
<el-dropdown-menu>
<el-dropdown-item
v-for="item in config.LANG"
:key="item.value"
:command="item"
>{{ item.name }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
13.2 逻辑
import { ref , getCurrentInstance , ComponentInternalInstance } from 'vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const config = reactive({
LANG:[
{name:"中文",value:'zh-cn'},
{name:'英文',value:'en'}
]
})
const configLang = ( item:any )=>{
let val:string = item.value;
let i18n = proxy?.$i18n;
i18n!.locale = val;
localStorage.setItem('lang',val);
}
13.3 main.ts导入i18n
//国际化
import i18n from './locales'
app.use(i18n)
13.4 locales/index.ts
import { createI18n } from 'vue-i18n'
import zh from './lang/zh-cn'
import en from './lang/en'
//element语言包
import zhCn from 'element-plus/dist/locale/zh-cn.mjs';
import English from 'element-plus/dist/locale/en.mjs';
const messages = {
'zh-cn':{
el:zhCn,
...zh
},
'en':{
el:English,
...en
}
}
const i18n = createI18n({
locale: localStorage.getItem('lang') || 'zh-cn', // 初始化配置语言
messages
})
export default i18n
13.5 lang/en.ts
export default {
login: {
slogan: 'The most concise basic permission framework system.' ,
describe: 'Electron + Vue3 + element plus based front-end solutions in the background.',
signInTitle: 'Sign in',
accountLogin: 'Account sign in',
mobileLogin: 'Mobile sign in',
rememberMe: 'Remember me',
forgetPassword: 'Forget password',
signIn: 'Sign in',
signInOther: 'Sign in with',
userPlaceholder: 'user / phone / email',
userError: 'Please input a user name',
PWPlaceholder: 'Please input a password',
PWError: 'Please input a password',
admin: 'Administrator',
user: 'User',
mobilePlaceholder: 'Mobile',
mobileError: 'Please input mobile',
smsPlaceholder: 'SMS Code',
smsError: 'Please input sms code',
smsGet: 'Get SMS Code',
smsSent: 'SMS sent to mobile number',
noAccount: 'No account?',
createAccount: 'Create a new account',
wechatLoginTitle: 'QR code sign in',
wechatLoginMsg: 'Please use wechat to scan and log in | Auto scan after 3 seconds of simulation',
wechatLoginResult: 'Scanned | Please click authorize login in the device'
},
user: {
dynamic: 'Dynamic',
info: 'User Info',
settings: 'Settings',
nightmode: 'night mode',
nightmode_msg: 'Suitable for low light environment,The current night mode is beta',
language: 'language',
language_msg: 'Translation in progress,Temporarily translated the text of this view',
}
}
13.6 lang/zh-cn.ts
export default {
login: {
slogan: '最精简的基础权限框架系统',
describe: '基于Electron + Vue3 + Element-Plus 的中后台前端解决方案。',
signInTitle: '用户登录',
accountLogin: '账号登录',
mobileLogin: '手机号登录',
rememberMe: '24小时免登录',
forgetPassword: '忘记密码',
signIn: '登录',
signInOther: '其他登录方式',
userPlaceholder: '用户名 / 手机 / 邮箱',
userError: '请输入用户名',
PWPlaceholder: '请输入密码',
PWError: '请输入密码',
admin: '管理员',
user: '用户',
mobilePlaceholder: '手机号码',
mobileError: '111请输入手机号码',
smsPlaceholder: '短信验证码',
smsError: '请输入短信验证码',
smsGet: '获取验证码',
smsSent: '已发送短信至手机号码',
noAccount: '还没有账号?',
createAccount: '创建新账号',
wechatLoginTitle: '二维码登录',
wechatLoginMsg: '请使用微信扫一扫登录 | 模拟3秒后自动扫描',
wechatLoginResult: '已扫描 | 请在设备中点击授权登录'
},
user: {
dynamic: '近期动态',
info: '个人信息',
settings: '设置',
nightmode: '黑夜模式',
nightmode_msg: '适合光线较弱的环境,当前黑暗模式为beta版本',
language: '语言',
language_msg: '翻译进行中,暂翻译了本视图的文本',
}
}
13.7 使用
template:非绑定
<h4>{{ $t('login.slogan') }}</h4>
template:绑定
<el-tab-pane :label="$t('login.accountLogin')">
js:
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
proxy?.$t('login.mobileError')
14、axios封装get和post
14.1 request.ts
import axios, { AxiosInstance, AxiosResponse } from 'axios';
const request: AxiosInstance = axios.create({
baseURL: '/api'
});
// 添加请求拦截器
request.interceptors.request.use(function (config) {
return config;
}, function (error) {
return Promise.reject(error);
});
// 添加响应拦截器
request.interceptors.response.use(function (response) {
return response;
}, function (error) {
return Promise.reject(error);
});
const http = {
get<T>(url: string, params?: any, config?: {}): Promise<T> {
return new Promise((resolve, reject) => {
request
.get<T>(url, { params,...config } )
.then((res: AxiosResponse<T>) => {
resolve(res.data);
})
.catch((error) => {
reject(error);
});
});
},
post<T>(url: string, data?: any, config?: {}): Promise<T> {
return new Promise((resolve, reject) => {
request
.post<T>(url, data, config)
.then((res: AxiosResponse<T>) => {
resolve(res.data);
})
.catch((error) => {
reject(error);
});
});
},
};
export default http;
14.2 api文件
import http from "@utils/request";
import { UserRuleForm , PhoneCodeForm , PhoneRuleForm } from '@interface/login'
interface ILoginRequest{
code:string
msg:string
data?:string | null
}
//图形验证码
export const captchaImage = ( data:{
key:string
} ):Promise<ArrayBuffer>=>{
return http.get<ArrayBuffer>('/captcha/image',data,{responseType:'arraybuffer'});
}
//用户登录
export const loginByJson = ( data:UserRuleForm ):Promise<ILoginRequest>=>{
return http.post<ILoginRequest>('/u/loginByJson',data)
}
//登录动态验证码
export const loginCaptcha = ( data:PhoneCodeForm ):Promise<ILoginRequest>=>{
return http.get<ILoginRequest>('/captcha/sendRegisterOrLoginCaptcha',data)
}
//手机验证码登录
export const loginByMobile = ( data:PhoneRuleForm ):Promise<ILoginRequest>=>{
return http.post<ILoginRequest>('/u/loginByMobile',data)
}
15、登录后
15.1 登录后-流程:
1. 如果用户输入的正确,点击【登录】:后端会给前端返回【token】,前端需要把token存储起来(本地存储)
2. 请求用户信息接口
前端在headers头中,把token传递过去
后端给前端返回当前登录用户的【用户信息】,其中关于登录的有:角色信息-角色权限编码
3. 请求获取路由接口,把角色信息-角色权限编码传递给后端,从而获得此用户的路由菜单权限树
15.2 获取用户信息
1. 接口
***传递token,要在:config.headers['Authorization'] 中赋值token
2. interface
export interface User {
id: string;
username: string;
realName: string;
userType: number;
email: string;
phone: string;
gender: number;
avatar: string;
enabled: number;
delFlag: number;
remark: string | null;
}
export interface Permission {
[index: number]: string;
}
export interface Unit {
id: string;
name: string;
code: string;
codeseq: string;
contact: string;
mobile: string;
email: string;
web: string;
parentId: string;
hasChildren: number;
system: number;
enabled: number;
leaderId: string | null;
createBy: string;
createTime: number | null;
updateBy: string | null;
updateTime: number | null;
address: string;
}
export interface Role {
id: string;
roleName: string;
rolePerm: string;
}
export interface IUserData {
code: string;
msg: string;
data: {
userInfo: User;
permissions: Permission;
units: Unit;
roles: Role[];
};
}
3. 调用方法=》进行数据存储
import { defineStore } from 'pinia'
import { getInfo } from '@api/user'
import { Role } from '@interface/user'
import { useMenuStore } from '@store/useMenuStore'
export const useUserStore = defineStore('userId', {
state: ():{
roles:Role[],
rolePerm:string
} => {
return {
roles:[],
rolePerm:''
}
},
getters:{},
actions:{
async getUserInfo(){
let res = await getInfo();
let { permissions ,roles , units , userInfo } = res.data;
this.roles = roles;
this.rolePerm = roles[0].rolePerm;
useMenuStore().getMenu();
}
},
persist: {
enabled: true, //开启数据缓存
strategies: [
{
storage: localStorage,//默认走session
paths: ['rolePerm']
}
]
}
})
15.3 获取路由
1. 接口
2. interface
export interface Meta {
title: string;
icon: string;
noCache: boolean;
link: string | null;
}
export interface Child {
id: string;
name: string;
path: string;
hidden: boolean;
component: string;
meta: Meta;
}
export interface Parent {
id: string;
name: string;
hidden: boolean;
redirect: string;
component: string;
alwaysShow: boolean;
query: string;
path: string;
meta: Meta;
children: Child[];
}
export interface IResponseData {
code: string;
msg: string;
data: Parent[];
}
3. 调用方法=》进行数据存储
import { defineStore } from 'pinia'
import { getUserMenu } from '@api/user'
import { useUserStore } from '@store/useUserStore'
import { Parent } from '@interface/user'
export const useMenuStore = defineStore('menuId', {
state: ():{
menu:Parent[]
}=> {
return {
menu:[]
}
},
getters:{},
actions:{
async getMenu(){
let res = await getUserMenu(useUserStore().rolePerm);
this.menu = res.data;
console.log('菜单',useUserStore().rolePerm,res );
}
},
persist: {
enabled: true, //开启数据缓存
strategies: [
{
storage: localStorage,//默认走session
paths: ['menu']
}
]
}
})
16、后台管理系统-骨架
16.1 窗口设置
//从login跳转到Home,只是路由的切换,窗口并没有换,所以窗口的配置会一直生效,那么就要修改窗口的:
//窗口大小
mainWindow.setSize(1200, 720);
//窗口最小值
mainWindow.setMinimumSize(1000, 500);
//窗口居中
mainWindow.center();
//窗口大小可以修改
mainWindow.setResizable(true);
16.2 后台架构布局
提示,用来调试:mainWindow.webContents.openDevTools();
<template>
<section class="aminui-wrapper">
<!--1级菜单-->
<div class="aminui-side-split">
<div class="aminui-side-split-top">
<router-link to="/">
<img class="logo" src="../assets/images/logo-r.png">
</router-link>
</div>
<div class="adminui-side-split-scroll">
<el-scrollbar>
<ul>
<li class="active">
<el-icon><ChatRound /></el-icon>
<p>首页</p>
</li>
<li>
<el-icon><ChatRound /></el-icon>
<p>新媒体</p>
</li>
</ul>
</el-scrollbar>
</div>
</div>
<!--2级菜单-->
<div class="aminui-side">
<div class="adminui-side-top">
<h2>首页</h2>
</div>
<div class="adminui-side-scroll">
<el-scrollbar>
<el-menu>
<el-menu-item index="1">
<el-icon><ChatRound /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="2">
<el-icon><ChatRound /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="3">
<el-icon><ChatRound /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="4">
<el-icon><ChatRound /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="5">
<el-icon><ChatRound /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="6">
<el-icon><ChatRound /></el-icon>
<span>仪表盘</span>
</el-menu-item>
</el-menu>
</el-scrollbar>
</div>
<div class="adminui-side-bottom">
<el-icon><el-icon-expand/></el-icon>
</div>
</div>
<!--右侧组件-->
<div class="aminui-body">
右侧内容
</div>
</section>
</template>
<script setup lang="ts">
import { onBeforeMount } from 'vue';
onBeforeMount(()=>{
window.electron.ipcRenderer.invoke('resize-window');
})
</script>
<style scoped lang="scss">
.aminui-wrapper{
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
.aminui-side-split{
display: flex;
flex-direction: column;
width:65px;
height: 100vh;
overflow: hidden;
background: #222b45;
.aminui-side-split-top {height: 49px;-webkit-app-region: drag;}
.aminui-side-split-top a {display: inline-block;width: 100%;height: 100%;display: flex;align-items: center;justify-content: center;}
.aminui-side-split-top .logo {height:30px;vertical-align: bottom;}
.adminui-side-split-scroll {overflow: auto;overflow-x:hidden;height: 100%;flex: 1;}
li {cursor: pointer;width: 65px;height: 65px;color: #fff;text-align: center;display: flex;flex-direction: column;align-items: center;justify-content: center;}
li i {font-size: 18px;}
li p {margin-top:5px;font-size:12px;}
li:hover {background: rgba(255, 255, 255, 0.1);}
li.active {background: #409EFF;}
}
.aminui-side{
display: flex;
flex-flow: column;
width:210px;
background: #fff;
box-shadow: 2px 0 8px 0 rgba(29,35,41,.05);
border-right: 1px solid #e6e6e6;
.adminui-side-top { -webkit-app-region: drag;border-bottom: 1px solid #ebeef5;height:49px;line-height: 50px;}
.adminui-side-top h2 {padding:0 20px;font-size: 17px;color: #3c4a54;}
.adminui-side-scroll {overflow: auto;overflow-x:hidden;flex: 1;}
.adminui-side-bottom {border-top: 1px solid #ebeef5;height:51px;cursor: pointer;display: flex;align-items: center;justify-content: center;}
.adminui-side-bottom i {font-size: 16px;}
.adminui-side-bottom:hover {color: var(--el-color-primary);}
}
.aminui-body{
flex:1;
}
}
.el-menu{
border-right:0px;
}
</style>
17、hooks
17.1 hooks是什么
vue3 中的 hooks 就是函数的一种写法,就是将文件的一些单独功能的 js 代码进行抽离出来进行封装使用。
它的主要作用是 Vue3 借鉴了 React 的一种机制,用于在函数组件中共享状态逻辑和副作用,从而实现代码的可复用性。
注意:其实 hooks 和 vue2 中的 mixin 有点类似,但是相对 mixins 而言, hooks 更清楚复用功能代码的来源, 更清晰易懂。
17.2 hooks优点
hooks 作为独立逻辑的组件封装,其内部的属性、函数等和外部组件具有响应式依附的作用。
自定义 hook 的作用类似于 vue2 中的 mixin 技术,使用方便,易于上手。
使用 Vue3 的组合 API 封装的可复用,高内聚低耦合。
17.3 自定义 hook 需要满足的规范
具备可复用功能,才需要抽离为 hooks 独立文件
函数名/文件名以 use 开头,形如: useXX
引用时将响应式变量或者方法显式解构暴露出来;
17.4 具体使用
//hooks文件
import { useUserStore } from '@store/useUserStore'
import { useMenuStore } from '@store/useMenuStore'
import router from '@router'
const useLogin = async ( token )=>{
//1. 存储token
localStorage.setItem('TOKEN', token );
//2. 获取用户信息 ==> 数据的存储和重构是在别的地方
await useUserStore().getUserInfo();
//3. 获取路由 ==> 数据的存储和重构是在别的地方
await useMenuStore().getMenu();
//4. 进入后台管理系统首页
router.push('/');
}
export default useLogin;
//vue文件
//引入hooks
import useLogin from '@hooks/useLogin'
//调用hooks
useLogin( res.data );
18、渲染一级菜单
18.1 动态组件
<el-icon>
<component :is="item.meta.icon.replace('el-icon-','')"></component>
</el-icon>
18.2 把数据传递给子组件
nextMenu.value = pmenu.value.children;
19、渲染二级菜单
<template>
<div v-if='nextMenu.length <= 0'>
没有子级菜单
</div>
<template
v-for='navItem in nextMenu'
:key='navItem'
>
<!--二级菜单渲染-->
<el-menu-item :index='navItem.path'>
<el-icon>
<component :is='navItem.meta?.icon.replace("el-icon-","") || "user" ' />
</el-icon>
<template #title>
<span>{{navItem.name}}</span>
</template>
</el-menu-item>
</template>
</template>
<script setup lang="ts">
import { PropType } from 'vue';
import { Parent } from '@interface/user'
defineProps({
nextMenu:{
type: Object as PropType<Parent[]>,
required: true
}
})
</script>
20、调整布局页面
20.1 layout为布局页面:把Home.vue修改成layout
20.2 显示右侧数据内容的路由结构 - 为后续添加动态路由做铺垫
{
path: '/',
component: () => import('@views/Home.vue'),
redirect: '/dashboard',
children: [{
path: '/dashboard',
component: () => import('@views/home/index.vue')
}]
}
21、动态添加路由
21.1 动态添加路由
router.addRoute('name', {
path:'/',
component:()=>import('')
})
21.2 需要把后端给前端返回的路由数据的component对应的value字符串修改成:component:()=>import(‘’)
export const beforeEach = ( to )=>{
initRouter()
//当前路由没有匹配到任何路由记录
if(to.matched.length == 0){
router.push(to.fullPath)
}
}
import { useMenuStore } from '@store/useMenuStore'
import router from '@router'
import { Parent } from '@interface/user'
export const beforeEach = ( to:any )=>{
if( to.path == '/login' ){
return ;
}
if( !localStorage.getItem('TOKEN') ){
return '/login'
}
//动态添加路由
initRouter();
//当前路由没有匹配到任何路由记录
if( to.matched.length == 0){
router.push( to.fullPath );
}
return true;
}
interface Child{
parentView: string;
path: string;
name: string;
meta: any;
redirect: string;
children?: Child[] | null;
component: any;
id?: string | undefined;
hidden?: boolean | undefined;
alwaysShow?: boolean | undefined;
query?: string | undefined;
}
interface Child extends Omit<Parent, 'children'> {
children?: Child[] | null;
}
//1. 动态添加路由 => 整个过程
const initRouter = ()=>{
let menu:Parent[] = useMenuStore().menu;
let menuRouter: Child[] = filterRouter(menu);
menuRouter = flatRoutes(menuRouter);
menuRouter.forEach((item:any) => {
router.addRoute(item.parentView == 'layout' ? 'layout' : '', item);
});
}
//2. 把component 重构成 箭头函数的形式
const filterRouter = (menu: Parent[]): Child[] => {
let arrRouter: Child[] = [];
menu.forEach((item: any) => {
var route: Child = {
parentView: item.parentView,
path: item.path,
name: item.name,
meta: item.meta,
redirect: item.redirect,
children: item.children ? filterRouter(item.children) : null,
component: loadComponent(item.component)
};
arrRouter.push(route);
});
return arrRouter;
};
//3. 对于component的调整
const modules: Record<string, () => Promise<any>> = import.meta.glob('@renderer/views/**/*.vue');
const modulesMap: Record<string, () => Promise<any>> = {};
Object.keys(modules).forEach((key) => {
const componentName = key.replace('/src/views', '').replace('.vue', '').replace('/index', '').replace('/', '');
if (key.includes('index')) {
modulesMap[`${componentName}/index`] = modules[key];
}
modulesMap[componentName] = modules[key];
});
//4. 根据modulesMap[key]返回对应的value值
const loadComponent = (component: string | null): (() => Promise<any>) | undefined => {
if (component) {
return modulesMap[component];
}
return;
};
//5. 路由扁平化
const flatRoutes = (routes: Child[], breadcrumb: Child[] = []): Child[] => {
let res: Child[] = [];
routes.forEach((route: Child) => {
const tmp = { ...route };
if (tmp.children) {
let childrenBreadcrumb: Child[] = [...breadcrumb];
childrenBreadcrumb.push(route);
let tmpRoute = { ...route };
delete tmpRoute.children;
res.push(tmpRoute);
let childrenRoutes = flatRoutes(tmp.children, childrenBreadcrumb);
childrenRoutes.map((item) => {
res.push(item);
});
} else {
let tmpBreadcrumb = [...breadcrumb];
tmpBreadcrumb.push(tmp);
res.push(tmp);
}
});
return res;
};
//后置
export const afterEach = ()=>{
console.log('后置');
}
22、切换路由
22.1 el-menu跳转
<el-menu router :default-active="route.path">
22.2 解决深度拷贝对象,出现循环引用的问题
//深度复制对象,并处理循环引用
import clone from 'rfdc'
const cloneRoutes = clone()(routes);
23、TopBar面包屑
23.1 TopBar.vue
<template>
<div class="topbar">
<div class="left-panel">
<el-breadcrumb separator-icon="ArrowRight">
<transition-group name="breadcrumb">
<template v-for="item in breadList" :key="item.path">
<el-breadcrumb-item v-if="item.path!='/'" :key="item.path">
<el-icon>
<component :is="item.meta?.icon.replace('el-icon-','')" />
</el-icon>
{{ item.meta?.title }}
</el-breadcrumb-item>
</template>
</transition-group>
</el-breadcrumb>
</div>
<div class="center-panel"></div>
<div class="right-panel">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeMount , ref , watch } from 'vue';
import { Parent } from '@interface/user'
import { useRoute } from 'vue-router';
const route = useRoute();
const breadList = ref<Parent[]>([]);
const getBreadcrumb = ()=>{
let matched:Parent[] = route.meta.breadcrumb as Parent[];
breadList.value = matched;
console.log( breadList.value );
}
onBeforeMount(()=>{
getBreadcrumb();
})
watch(route,()=>{
getBreadcrumb();
})
</script>
<style scoped lang="scss">
.topbar{
display: flex;
justify-content: space-between;
height: 49px;
border-bottom: 1px solid #ebeef5;
background: #fff;
.left-panel{
display: flex;
align-items: center;
}
.center-panel{
flex:1;
-webkit-app-region:drag;
}
.right-panel{
display: flex;
align-items: center;
}
}
.el-breadcrumb{
margin-left: 15px;
.el-breadcrumb__inner .el-icon{
font-size:14px;
margin-right: 5px;
float:left;
}
}
</style>
23.2 UserBar.vue
<template>
<div class="user-bar">
<!--退出登录-->
<el-dropdown class="panel-item">
<div class="user-avatar">
<el-avatar :size="30" src="" />
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!--消息-->
<div class="panel-item">
<el-icon><ChatDotRound /></el-icon>
</div>
<!--下载-->
<div class="panel-item">
<el-icon><Download /></el-icon>
</div>
<!--缩小-->
<div class="panel-item">
<el-icon><Minus /></el-icon>
</div>
<!--放大-->
<div class="panel-item">
<el-icon><FullScreen /></el-icon>
</div>
<!--关闭-->
<div class="panel-item">
<el-icon><Close /></el-icon>
</div>
</div>
</template>
<style scoped lang="scss">
.user-bar{
display: flex;
align-items: center;
height: 100%;
.panel-item{
display: flex;
align-items: center;
padding: 0 10px;
height: 100%;
cursor: pointer;
.user-avatar{
display: flex;
align-items: center;
}
&:hover{
background-color: rgba(0,0,0,0.1);
}
}
}
</style>
24、标签页
24.1 布局
<template>
<div class="adminui-tags">
<ul ref="tags">
<!-- 遍历标签列表 -->
<li
v-for="tag in viewTags"
:key="tag.path"
:class="isActive(tag)?'active':''"
>
<!-- 路由链接 -->
<router-link :to="tag.path">
<!-- 如果标签不是固定标签,则显示关闭图标 -->
<span>{{ tag.name }}</span>
<el-icon v-if="tag.affix==false" @click.prevent.stop='closeSelectedTag(tag)'>
<el-icon-close/>
</el-icon>
</router-link>
</li>
</ul>
</div>
</template>
<style scoped>
.adminui-tags {height:35px;background: #fff;border-bottom: 1px solid #e6e6e6;}
.adminui-tags ul {display: flex;overflow: hidden;}
.adminui-tags li {font-size:12px;cursor: pointer;display: inline-block;float: left;height:34px;line-height: 34px;position: relative;flex-shrink: 0;}
.adminui-tags li::after {content: " ";width:1px;height:100%;position: absolute;right:0px;background-image: linear-gradient(#fff, #e6e6e6);}
.adminui-tags li a {display: inline-block;padding:0 10px;width:100%;height:100%;color: #999;text-decoration:none;display: flex;align-items: center;}
.adminui-tags li i {margin-left:10px;border-radius: 3px;width:18px;height:18px;display: flex;align-items: center;justify-content: center;}
.adminui-tags li i:hover {background: rgba(0,0,0,.2);color: #fff;}
.adminui-tags li:hover {background: #ecf5ff;}
.adminui-tags li.active {background: #409EFF;}
.adminui-tags li.active a {color: #fff;}
</style>
25、用户信息-渲染头像
interface IUserInfo {
id: string;
username: string;
realName: string;
userType: number;
email: string;
phone: string;
gender: number;
avatar: string;
enabled: number;
delFlag: number;
remark: string | null;
}
26、退出登录
26.1 部分逻辑
//退出登录
import { ElMessage , ElMessageBox } from 'element-plus';
import { useRouter } from 'vue-router'
const router = useRouter();
const outLogin = ()=>{
ElMessageBox.confirm("确认",'是否退出登录',{
type:'warning',
confirmButtonText:'退出'
}).then(()=>{
ElMessage({
type:'success',
message:'退出登录'
})
window.electron.ipcRenderer.invoke('out-login');
localStorage.setItem('TOKEN','');
router.replace({
path:'/login'
});
}).catch(()=>{
ElMessage({
type:'info',
message:'取消退出'
})
})
}
26.2 主进程操作
//退出登录
ipcMain.handle('out-login',() => {
//窗口大小
mainWindow.setSize(900, 670);
//窗口居中
mainWindow.center();
//窗口大小可以修改
mainWindow.setResizable(false);
})
27、记住密码
<el-checkbox label="记住密码" v-model="checkPassword" @change="onMemoPassword"></el-checkbox>
//初始化
onBeforeMount(()=>{
let userPwd: string | null = localStorage.getItem('user-pwd');
if (userPwd) {
let { username, password }: { username: string, password: string } = JSON.parse(userPwd);
ruleForm.username = Decrypt(username);
ruleForm.password = Decrypt(password);
}
})
//hooks
import useMemoPassword from '@hooks/useMemoPassword'
let { memoVal, onMemoPassword } = useMemoPassword();
let checkPassword: Ref<boolean> = ref(memoVal);
const setMemoPassword = () => {
if (checkPassword.value) {
const userPwd: {
username: string,
password: string
} = {
username: Encrypt(ruleForm.username) as string,
password: Encrypt(ruleForm.password) as string
}
localStorage.setItem('user-pwd', JSON.stringify(userPwd));
} else {
localStorage.removeItem('user-pwd');
}
}
//hooks
import { ref, Ref } from 'vue'
interface MemoPassword {
memoVal: Ref<boolean>,
onMemoPassword: (value: boolean) => void
}
function useMemoPassword(): MemoPassword {
let memoVal: Ref<boolean> = ref(localStorage.getItem('memoPassword') === 'true');
const onMemoPassword = (value: boolean) => {
localStorage.setItem('memoPassword', String(memoVal.value = value));
}
return {
memoVal,
onMemoPassword
}
}
export default useMemoPassword;
28、全屏、最小化、关闭
全屏:JS方式
const isFull: boolean = !!(document.webkitIsFullScreen || document.mozFullScreen ||
document.msFullscreenElement || document.fullscreenElement);
if (isFull) { // 缩小
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
} else { // 放大
const element: HTMLElement = document.documentElement;
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
}
}
最大化:electron方式
if( mainWindow.isFullScreen() ){
mainWindow.setFullScreen(false);
}else{
mainWindow.setFullScreen(true);
}
最小化
win.minimize()
关闭窗口
close 方法用于关闭窗口,可以通过监听 close 事件来执行一些自定义操作,并有机会取消关闭操作。
destroy 方法用于彻底销毁一个窗口,不会触发 close 事件,并立即释放与窗口相关的所有资源。
关闭软件
app.quit() 方法用于退出整个 Electron 应用程序,可以通过监听 before-quit 事件来执行一些预处理操作。
app.exit() 方法用于立即终止整个 Electron 应用程序的进程,不会触发任何事件。
29、工作流
29.1 什么是工作流
工作流(Workflow),指“业务过程的部分或整体”。
29.2 接下来要做的模块
系统:角色管理、用户管理
工作流:教学(科目管理、班级管理)、新媒体、招生、办理(缴费和入学)、就业
- Title: 3. 基础权限框架(鹿线开源)
- Author: 明廷盛
- Created at : 2026-02-12 01:17:04
- Updated at : 2026-01-18 15:40:00
- Link: https://blog.20040424.xyz/2026/02/12/⚔️实战项目/3. 基础权限框架(鹿线开源)/
- License: All Rights Reserved © 明廷盛





