请求


前端通过 Vite 开发代理把以 VITE_APP_BASE_API(即 /dev-api)开头的请求转发到后端:开发时代码里请求 /dev-api/xxx,Vite server 按 proxy['/dev-api'] 把它重写为http://localhost:8080/xxx,从而避开跨域并直连后端。
CSS
该项目采用了预处理器Sass, 采用的是 Element Plus + 自定义 CSS ( ruoyi.scss )的组合方案

在src\main.js,导入全局样式文件。

src/assets/styles 文件夹的项目结构
|--src\assets\styles\index.scss // 主文件,导入所有部分文件
|--src\assets\styles\btn.scss - 按钮样式
|--src\assets\styles\element-ui.scss- Element UI 组件样式
|--src\assets\styles\mixin.scss- SCSS 混合器
|--src\assets\styles\ruoyi.scss - 若依框架样式
|--src\assets\styles\sidebar.scss- 侧边栏样式
|--src\assets\styles\transition.scss - 过渡动画样式
|--src\assets\styles\variables.module.scss - CSS 变量定义
src\assets\styles\index.scss
全局基础样式重置 (第9-36行)
- HTML/Body 样式:设置全屏高度、字体平滑渲染、字体栈
- Box-sizing 统一 :使用
inherit确保所有元素继承盒模型设置 - 字体优化:针对不同浏览器的字体渲染优化

工具类系统 (第38-100行)
提供了丰富的工具类,包括:
- 布局类 :
.fr(右浮动)、.fl(左浮动) - 间距类 :
.pr-5、.pl-5、.no-padding - 显示类 :
.block、.inlineBlock - 交互类 :
.pointer、.clearfix

组件样式定义 (第102-192行)
- aside 侧边栏:包含渐变背景和悬停效果
- 容器样式 :
.app-container、.components-container - 导航栏 :
.sub-navbar带有渐变背景和状态变化 - 链接样式 :
.link-type统一的链接交互效果

尝试使用



src\assets\styles\variables.module.scss
基础色彩系统 (第1-9行)

菜单主题系统 (第11-22行)

自定义暗色主题 (注释部分,第24-37行)

Element UI 色彩变量 (第39-43行)

布局变量 (第45行)

CSS Modules 的 :export 指令 (第47-65行)

尝试使用
html
<template>
<div class="body">
<a href="https://www.baidu.com">百度</a>
<button class="btn">蓝色按钮</button>
<!-- <button class="btn">红色按钮</button> -->
</div>
</template>
<style scoped lang="scss">
// 导入项目样式变量
@import '@/assets/styles/variables.module.scss';
.body {
padding: 20px;
background: $base-menu-background;
min-height: 100vh;
a {
color: $--color-primary;
margin-right: 20px;
&:hover {
color: $light-blue;
}
}
.btn {
background: $--color-primary;
// background: $--color-danger;
}
}
</style>

src\assets\styles\btn.scss
核心混合器设计 (第3-14行)

色彩按钮系统 (第16-42行)
基于混合器生成了7种颜色变体:
.blue-btn- 蓝色按钮.light-blue-btn- 浅蓝色按钮.red-btn- 红色按钮.pink-btn- 粉色按钮.green-btn- 绿色按钮.tiffany-btn- 蒂芙尼蓝按钮.yellow-btn- 黄色按钮

高级交互按钮 (.pan-btn) (第44-82行)


尝试使用
html
<template>
<div class="body">
<a href="https://www.baidu.com">百度</a>
<button class="btn light-blue-btn">蓝色按钮</button>
<button class="btn red-btn">红色按钮</button>
</div>
</template>
<style scoped lang="scss">
.body {
padding: 20px;
background: $base-menu-background;
min-height: 100vh;
a {
color: $--color-primary;
margin-right: 20px;
&:hover {
color: $light-blue;
}
}
.btn {
margin: 10px;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
}
</style>

src\assets\styles\element-ui.scss
面包屑导航优化 (第3-6行)

文件上传组件优化 (第8-16行)

表格单元格样式优化 (第18-22行)

工具类样式定义 (第24-47行)



对话框定位修复 (第49-55行)

上传容器优化 (第57-67行)

下拉菜单优化 (第69-74行)

日期选择器修复 (第76-84行)

折叠菜单优化 (第86-92行)

下拉链接颜色优化 (第94-96行)

src\assets\styles\mixin.scss
清除浮动混合器 (第1-7行)

自定义滚动条混合器 (第9-22行)

全屏相对定位混合器 (第24-28行)

百分比宽度居中混合器 (第30-34行)

三角形生成混合器 (第36-66行)

src\layout\index.vue

src\assets\styles\ruoyi.scss
间距工具类系统 (第7-54行)

标题样式统一 (第56-61行)

Element UI 组件深度定制 (第63-92行)

表单布局系统 (第94-101行)

分页组件优化 (第104-133行)

树形组件样式 (第112-119行)

表格操作优化 (第135-154行)

列表组件样式 (第156-175行)

卡片组件定制 (第181-194行)

src\assets\styles\sidebar.scss
主容器布局 (第3-12行)

侧边栏容器核心样式 (第14-29行)

Element UI 组件定制 (第30-55行)

菜单样式系统 (第57-112行)

收起状态样式 (第114-169行)

移动端适配 (第175-193行)

动画控制 (第195-200行)

当菜单折叠时的样式(

应用


src\assets\styles\transition.scss
基础淡入淡出动画 (第3-12行)

滑动淡入淡出动画 (第14-29行)

面包屑导航动画 (第31-49行)

index.html
Vue 应用的 HTML 模板文件。
这部分设置了基本的HTML文档属性,包括字符集、浏览器兼容性、渲染模式、视口设置等
html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="/favicon.ico">
<title>RuoYi-Vue-Plus管理系统</title>
Vue 会将应用挂载到 <div id="app"></div> 中。
html
<body>
<div id="app">
<div id="loader-wrapper">
<div id="loader"></div>
<div class="loader-section section-left"></div>
<div class="loader-section section-right"></div>
<div class="load_title">正在加载系统资源,请耐心等待</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
src\main.js
src/main.js 负责创建 Vue 应用实例,并将根组件(通常是 App.vue)挂载到 index.html 中的 div#app 中。
核心依赖导入:
javascript
import { createApp } from 'vue'
UI框架和工具库导入:
javascript
import ElementPlus from 'element-plus'
import locale from 'element-plus/lib/locale/lang/zh-cn'// 中文语言
import Cookies from 'js-cookie'
全局样式和资源:
javascript
import '@/assets/styles/index.scss'
import 'virtual:svg-icons-register'
核心模块导入:
javascript
import App from './App'
import store from './store'
import router from './router'
自定义组件和工具:
javascript
import directive from './directive'
import plugins from './plugins'
import { download } from '@/utils/request'
全局组件注册:
javascript
// 全局组件挂载
app.component('DictTag', DictTag)
app.component('Pagination', Pagination)
app.component('TreeSelect', TreeSelect)
app.component('FileUpload', FileUpload)
app.component('ImageUpload', ImageUpload)
app.component('ImagePreview', ImagePreview)
app.component('RightToolbar', RightToolbar)
app.component('Editor', Editor)
全局方法挂载:
javascript
// 全局方法挂载
app.config.globalProperties.useDict = useDict
app.config.globalProperties.getConfigKey = getConfigKey
app.config.globalProperties.updateConfigByKey = updateConfigByKey
app.config.globalProperties.download = download
app.config.globalProperties.parseTime = parseTime
app.config.globalProperties.resetForm = resetForm
app.config.globalProperties.handleTree = handleTree
app.config.globalProperties.addDateRange = addDateRange
app.config.globalProperties.selectDictLabel = selectDictLabel
app.config.globalProperties.selectDictLabels = selectDictLabels
插件和中间件使用:使用路由、状态管理和自定义插件
javascript
app.use(router)
app.use(store)
app.use(plugins)
app.use(elementIcons)
app.component('svg-icon', SvgIcon)
注册自定义指令:
javascript
directive(app)
ElementPlus 配置:
javascript
// 使用element-plus 并且设置全局的大小
app.use(ElementPlus, {
locale: locale,
// 支持 large、default、small
size: Cookies.get('size') || 'default'
})
应用挂载:
javascript
app.mount('#app')
src\components
javascript
Breadcrumb/ - 面包屑导航组件
DictTag/ - 字典标签组件
Editor/ - 富文本编辑器组件
FileUpload/ - 文件上传组件
Hamburger/ - 汉堡菜单按钮组件
HeaderSearch/ - 头部搜索组件
IconSelect/ - 图标选择器组件
ImagePreview/ - 图片预览组件
iFrame/ - 内嵌页面组件
ImageUpload/ - 图片上传组件
Pagination/ - 分页组件
ParentView/ - 父视图容器组件
RightToolbar/ - 右侧工具栏组件
Screenfull/ - 全屏切换组件
SizeSelect/ - 尺寸选择器组件
SvgIcon/ - SVG图标组件
svgicon.js - SVG图标配置文件
TopNav/ - 顶部导航组件
TreeSelect/ - 树形选择器组件
RuoYi/ - 若依框架特定组件
Breadcrumb\index.vue
实现了一个动态的面包屑导航,显示当前页面的层级路径
模板结构:
html
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
<span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">{{ item.meta.title }}</span>
<a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
- 使用Element Plus的el-breadcrumb组件作为基础
- 通过transition-group实现面包屑的动画效果
- 最后一项和设置noRedirect的路由项显示为纯文本,其他为可点击链接
核心功能实现:
路由处理:
javascript
const route = useRoute();
const levelList = ref([])
function getBreadcrumb() {
// only show routes with meta.title
let matched = route.matched.filter(item => item.meta && item.meta.title);
const first = matched[0]
// 判断是否为首页
if (!isDashboard(first)) {
matched = [{ path: '/index', meta: { title: '首页' } }].concat(matched)
}
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
}
function isDashboard(route) {
const name = route && route.name
if (!name) {
return false
}
return name.trim() === 'Index'
}
getBreadcrumb()函数用于生成面包屑数据- 过滤出有meta.title的路由
- 如果不是首页,自动添加首页到面包屑开头
- 过滤掉设置了
breadcrumb: false的路由
导航逻辑:
javascript
function handleLink(item) {
const { redirect, path } = item // 解构路由项中的redirect和path属性
if (redirect) { // 如果存在重定向路径,则跳转到重定向路径
router.push(redirect)
return
}
router.push(path) // 否则跳转到常规路径
}
handleLink()函数处理面包屑点击事件- 支持redirect重定向和path路径导航
- 使用
@click.prevent阻止默认事件
响应式更新:
javascript
watchEffect(() => {
if (route.path.startsWith('/redirect/')) {
return
}
getBreadcrumb()
})
- 使用watchEffect监听路由变化
- 排除重定向路由的更新
- 路由变化时自动更新面包屑
样式设置:
css
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
- 设置面包屑的基本样式
- 不可点击项使用灰色显示
- 设置合适的间距和行高

DictTag\index.vue
显示字典标签的组件
模板结构:
html
<template>
<div>
<template v-for="(item, index) in options">
<template v-if="values.includes(item.value)">
<span
v-if="(item.elTagType == 'default' || item.elTagType == '') && (item.elTagClass == '' || item.elTagClass == null)"
:key="item.value"
:index="index"
:class="item.elTagClass"
>{{ item.label + " " }}</span>
<el-tag
v-else
:disable-transitions="true"
:key="item.value + ''"
:index="index"
:type="item.elTagType === 'primary' ? '' : item.elTagType"
:class="item.elTagClass"
>{{ item.label + " " }}</el-tag>
</template>
</template>
<template v-if="unmatch && showValue">
{{ unmatchArray | handleArray }}
</template>
</div>
</template>
- 使用Element Plus的el-tag组件作为基础
- 支持多个标签的展示
- 每个标签可以自定义类型、样式和效果
组件属性:
javascript
const props = defineProps({
// 数据
options: {
type: Array,
default: null,
},
// 当前的值
value: [Number, String, Array],
// 当未找到匹配的数据时,显示value
showValue: {
type: Boolean,
default: true,
},
separator: {
type: String,
default: ",",
}
});
- options: 字典选项数组
- value: 需要显示的值,支持数字、字符串或数组类型
核心逻辑:
values 计算属性:
javascript
const values = computed(() => {
if (props.value === null || typeof props.value === 'undefined' || props.value === '') return [];
return Array.isArray(props.value) ? props.value.map(item => '' + item) : String(props.value).split(props.separator);
});
- 处理不同类型的输入值(数组/字符串)
- 统一转换为字符串数组格式
- 处理空值情况
unmatch 计算属性:
javascript
const unmatch = computed(() => {
unmatchArray.value = [];
// 没有value不显示
if (props.value === null || typeof props.value === 'undefined' || props.value === '' || props.options.length === 0) return false
// 传入值为数组
let unmatch = false // 添加一个标志来判断是否有未匹配项
values.value.forEach(item => {
if (!props.options.some(v => v.value === item)) {
unmatchArray.value.push(item)
unmatch = true // 如果有未匹配项,将标志设置为true
}
})
return unmatch // 返回标志的值
});
- 检测哪些值在 options 中找不到对应项
- 将未匹配项存储到 unmatchArray 中供后续显示
- 返回是否有未匹配项的布尔值
辅助函数:
javascript
function handleArray(array) {
if (array.length === 0) return "";
return array.reduce((pre, cur) => {
return pre + " " + cur;
});
}
handleArray 函数用于将未匹配的数组转换为显示字符串
样式设置:
css
.el-tag + .el-tag {
margin-left: 10px;
}
没用上

Editor\index.vue
vue-quill 富文本编辑器组件
模板结构:
html
<template>
<div>
<el-upload
:action="uploadUrl"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
class="editor-img-uploader"
name="file"
:show-file-list="false"
:headers="headers"
ref="uploadRef"
v-if="type == 'url'"
>
</el-upload>
<div class="editor">
<quill-editor
ref="quillEditorRef"
v-model:content="content"
contentType="html"
@textChange="(e) => $emit('update:modelValue', content)"
:options="options"
:style="styles"
/>
</div>
</div>
</template>
el-upload:专门处理图片上传,但被隐藏(通过 CSS 设置 display: none)quill-editor:提供富文本编辑功能
组件属性:
javascript
const props = defineProps({
/* 编辑器的内容 */
modelValue: {
type: String,
},
/* 高度 */
height: {
type: Number,
default: null,
},
/* 最小高度 */
minHeight: {
type: Number,
default: null,
},
/* 只读 */
readOnly: {
type: Boolean,
default: false,
},
/* 上传文件大小限制(MB) */
fileSize: {
type: Number,
default: 5,
},
/* 类型(base64格式、url格式) */
type: {
type: String,
default: "url",
}
});
编辑器配置详解:
javascript
const options = ref({
theme: "snow",
bounds: document.body,
debug: "warn",
modules: {
// 工具栏配置
toolbar: {
container: [
["bold", "italic", "underline", "strike"], // 加粗 斜体 下划线 删除线
["blockquote", "code-block"], // 引用 代码块
[{ list: "ordered" }, { list: "bullet"} ], // 有序、无序列表
[{ indent: "-1" }, { indent: "+1" }], // 缩进
[{ size: ["small", false, "large", "huge"] }], // 字体大小
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
[{ align: [] }], // 对齐方式
["clean"], // 清除文本格式
["link", "image", "video"] // 链接、图片、视频
],
handlers: {
image: function (value) {
if (value) {
// 调用element图片上传
document.querySelector(".editor-img-uploader>.el-upload").click();
} else {
Quill.format("image", true);
}
},
},
}
},
placeholder: "请输入内容",
readOnly: props.readOnly,
});
核心功能实现:
图片上传流程:
javascript
// 图片上传前拦截
function handleBeforeUpload(file) {
const type = ["image/jpeg", "image/jpg", "image/png", "image/svg"];
const isJPG = type.includes(file.type);
//检验文件格式
if (!isJPG) {
proxy.$modal.msgError(`图片格式错误!`);
return false;
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy.$modal.loading("正在上传文件,请稍候...");
return true;
}
上传成功处理:
javascript
// 图片上传成功返回图片地址
function handleUploadSuccess(res, file) {
// 如果上传成功
if (res.code == 200) {
// 获取富文本实例
let quill = toRaw(quillEditorRef.value).getQuill();
// 获取光标位置
let length = quill.selection.savedRange.index;
// 插入图片,res为服务器返回的图片链接地址
quill.insertEmbed(length, "image", res.data.url);
// 调整光标到最后
quill.setSelection(length + 1);
proxy.$modal.closeLoading();
} else {
proxy.$modal.loading(res.msg);
proxy.$modal.closeLoading();
}
}
数据双向绑定:
javascript
const content = ref("");
watch(() => props.modelValue, (v) => {
if (v !== content.value) {
content.value = v === undefined ? "<p></p>" : v;
}
}, { immediate: true });
html
<quill-editor
ref="quillEditorRef"
v-model:content="content"
contentType="html"
@textChange="(e) => $emit('update:modelValue', content)"
:options="options"
:style="styles"
/>
样式自适应:
javascript
const styles = computed(() => {
let style = {};
if (props.minHeight) {
style.minHeight = `${props.minHeight}px`;
}
if (props.height) {
style.height = `${props.height}px`;
}
return style;
});
样式设置:

在通知公告处使用

FileUpload\index.vue
文件上传。
模板结构:
html
<template>
<div class="upload-file">
<el-upload
multiple
:action="uploadFileUrl"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:on-success="handleUploadSuccess"
:show-file-list="false"
:headers="headers"
class="upload-file-uploader"
ref="fileUpload"
>
<!-- 上传按钮 -->
<el-button type="primary">选取文件</el-button>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip">
请上传
<template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
<template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
的文件
</div>
<!-- 文件列表 -->
<transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
<li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
<el-link :href="`${file.url}`" :underline="false" target="_blank">
<span class="el-icon-document"> {{ getFileName(file.name) }} </span>
</el-link>
<div class="ele-upload-list__item-content-action">
<el-link :underline="false" @click="handleDelete(index)" type="danger">删除</el-link>
</div>
</li>
</transition-group>
</div>
</template>
组件属性:
javascript
const props = defineProps({
modelValue: [String, Object, Array],
// 数量限制
limit: {
type: Number,
default: 5,
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 5,
},
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: {
type: Array,
default: () => ["doc", "xls", "ppt", "txt", "pdf"],
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
}
});
核心功能实现:
文件上传验证:
javascript
// 上传前校检格式和大小
function handleBeforeUpload(file) {
// 校检文件类型
if (props.fileType.length) {
const fileName = file.name.split('.');
const fileExt = fileName[fileName.length - 1];
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
if (!isTypeOk) {
proxy.$modal.msgError(`文件格式不正确, 请上传${props.fileType.join("/")}格式文件!`);
return false;
}
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy.$modal.loading("正在上传文件,请稍候...");
number.value++;
return true;
}
文件列表管理:
javascript
watch(() => props.modelValue, async val => {
if (val) {
let temp = 1;
// 首先将值转为数组
let list;
if (Array.isArray(val)) {
list = val;
} else {
await listByIds(val).then(res => {
list = res.data.map(oss => {
oss = { name: oss.originalName, url: oss.url, ossId: oss.ossId };
return oss;
});
})
}
// 然后将数组转为对象数组
fileList.value = list.map(item => {
item = {name: item.name, url: item.url, ossId: item.ossId};
item.uid = item.uid || new Date().getTime() + temp++;
return item;
});
} else {
fileList.value = [];
return [];
}
},{ deep: true, immediate: true });
上传成功处理:
javascript
// 上传成功回调
function handleUploadSuccess(res, file) {
if (res.code === 200) {
uploadList.value.push({ name: res.data.fileName, url: res.data.url, ossId: res.data.ossId });
uploadedSuccessfully();
} else {
number.value--;
proxy.$modal.closeLoading();
proxy.$modal.msgError(res.msg);
proxy.$refs.fileUpload.handleRemove(file);
uploadedSuccessfully();
}
}
// 上传结束处理
function uploadedSuccessfully() {
if (number.value > 0 && uploadList.value.length === number.value) {
fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value);
uploadList.value = [];
number.value = 0;
emit("update:modelValue", listToString(fileList.value));
proxy.$modal.closeLoading();
}
}
文件删除功能:
javascript
// 删除文件
function handleDelete(index) {
let ossId = fileList.value[index].ossId;
delOss(ossId);
fileList.value.splice(index, 1);
emit("update:modelValue", listToString(fileList.value));
}
数据格式转换:
javascript
// 对象转成指定字符串分隔
function listToString(list, separator) {
let strs = "";
separator = separator || ",";
for (let i in list) {
if(list[i].ossId) {
strs += list[i].ossId + separator;
}
}
return strs != "" ? strs.substr(0, strs.length - 1) : "";
}
样式设置:
css
.upload-file-uploader {
margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
border: 1px solid #e4e7ed;
line-height: 2;
margin-bottom: 10px;
position: relative;
}
.upload-file-list .ele-upload-list__item-content {
display: flex;
justify-content: space-between;
align-items: center;
color: inherit;
}
.ele-upload-list__item-content-action .el-link {
margin-right: 10px;
}
在文件管理处使用

Hamburger\index.vue
模板结构:
html
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
动画状态:
- 接收
isActive布尔值,控制组件状态 - 点击时触发
toggleClick事件通知父组件
html
<script setup>
defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits()
const toggleClick = () => {
emit('toggleClick');
}
</script>
样式部分:
html
<style scoped>
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
.hamburger.is-active {
transform: rotate(180deg);
}
</style>
在src\layout\components\Navbar.vue 处使用

HeaderSearch\index.vue
模糊搜索
模板结构:
- 点击触发搜索框显示/隐藏
- 使用 Element Plus 的 Select 组件,支持远程搜索
html
<template>
<div :class="{ 'show': show }" class="header-search">
<svg-icon class-name="search-icon" icon-class="search" @click.stop="click" />
<el-select
ref="headerSearchSelectRef"
v-model="search"
:remote-method="querySearch"
filterable
default-first-option
remote
placeholder="Search"
class="header-search-select"
@change="change"
>
<el-option v-for="option in options" :key="option.item.path" :value="option.item" :label="option.item.title.join(' > ')" />
</el-select>
</div>
</template>
数据状态管理:
javascript
const search = ref(''); // 搜索关键词
const options = ref([]); // 搜索结果选项
const searchPool = ref([]); // 搜索池(所有可搜索的路由)
const show = ref(false); // 控制搜索框显示/隐藏
const fuse = ref(undefined); // Fuse.js 实例
const routes = computed(() => usePermissionStore().routes); // 系统路由
核心功能实现:
搜索逻辑实现:
搜索逻辑简单高效:
- 使用 Fuse.js 进行模糊搜索
- 输入为空时清空搜索结果
- 输入非空时返回模糊匹配结果
javascript
function querySearch(query) {
if (query !== '') {
options.value = fuse.value.search(query)
} else {
options.value = []
}
}
Fuse.js 初始化:
- 双字段搜索:同时搜索标题和路径
- 权重分配:标题匹配权重更高(0.7),路径匹配权重较低(0.3)
- 模糊阈值:0.4 的阈值提供了良好的模糊匹配效果
javascript
function initFuse(list) {
fuse.value = new Fuse(list, {
shouldSort: true, // 启用排序
threshold: 0.4, // 模糊匹配阈值
location: 0, // 匹配位置权重
distance: 100, // 匹配距离权重
minMatchCharLength: 1, // 最小匹配字符长度
keys: [{
name: 'title', // 搜索字段:标题
weight: 0.7 // 权重:0.7
}, {
name: 'path', // 搜索字段:路径
weight: 0.3 // 权重:0.3
}]
})
}
路由数据处理:
- 递归遍历:处理多层级路由结构
- 标题拼接:将父级和当前级标题连接成面包屑形式
- 查询参数处理:保留路由的查询参数
- 隐藏路由过滤:排除隐藏的路由项
javascript
function generateRoutes(routes, basePath = '', prefixTitle = [], query = {}) {
let res = []
for (const r of routes) {
// 跳过隐藏路由
if (r.hidden) { continue }
const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path;
const data = {
path: !isHttp(r.path) ? getNormalPath(basePath + p) : r.path,
title: [...prefixTitle]
}
if (r.meta && r.meta.title) {
data.title = [...data.title, r.meta.title]
if (r.redirect !== 'noRedirect') {
res.push(data)
}
}
if (r.query) {
data.query = r.query
}
// 递归处理子路由
if (r.children) {
const tempRoutes = generateRoutes(r.children, data.path, data.title, data.query)
if (tempRoutes.length >= 1) {
res = [...res, ...tempRoutes]
}
}
}
return res
}
标题拼接:

搜索结果处理:
- 智能路由识别:区分内部路由和外部链接
- 外部链接处理:在新窗口打开外部链接
- 内部路由跳转:使用 Vue Router 进行页面导航
- 状态重置:搜索完成后重置搜索状态
javascript
function change(val) {
const path = val.path;
const query = val.query;
if (isHttp(path)) {
// http(s):// 路径新窗口打开
const pindex = path.indexOf("http");
window.open(path.substr(pindex, path.length), "_blank");
} else {
if (query) {
router.push({ path: path, query: JSON.parse(query) });
} else {
router.push(path)
}
}
search.value = ''
options.value = []
nextTick(() => {
show.value = false
})
}
- 外部链接处理:

交互状态管理:
- 点击外部关闭:点击搜索框外部时自动关闭
- 自动聚焦:显示搜索框时自动获取焦点
- 事件监听管理:根据状态动态添加/移除事件监听
javascript
// 显示/隐藏搜索框
function click() {
show.value = !show.value
if (show.value) {
headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
}
};
// 关闭搜索框
function close() {
headerSearchSelectRef.value && headerSearchSelectRef.value.blur()
options.value = []
show.value = false
}
// 监听显示状态变化,管理事件监听
watch(show, (value) => {
if (value) {
document.body.addEventListener('click', close)
} else {
document.body.removeEventListener('click', close)
}
})
- 自动聚焦、点击外部关闭

样式设计:
- 平滑过渡:0.2s 的宽度过渡动画
- 简洁设计:无边框、仅有底部下划线
- 响应式展开:点击图标后平滑展开搜索框
css
<style lang='scss' scoped>
.header-search {
font-size: 0 !important;
.search-icon {
cursor: pointer;
font-size: 18px;
vertical-align: middle;
}
.header-search-select {
font-size: 18px;
transition: width 0.2s;
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
display: inline-block;
vertical-align: middle;
:deep(.el-input__inner) {
border-radius: 0;
border: 0;
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid #d9d9d9;
vertical-align: middle;
}
}
&.show {
.header-search-select {
width: 210px;
margin-left: 10px;
}
}
}
</style>
IconSelect\index.vue
图标选择器组件
模板结构:
css
<template>
<div class="icon-body">
<el-input
v-model="iconName"
class="icon-search"
clearable
placeholder="请输入图标名称"
@clear="filterIcons"
@input="filterIcons"
>
<template #suffix><i class="el-icon-search el-input__icon" /></template>
</el-input>
<div class="icon-list">
<div class="list-container">
<div v-for="(item, index) in iconList" class="icon-item-wrapper" :key="index" @click="selectedIcon(item)">
<div :class="['icon-item', { active: activeIcon === item }]">
<svg-icon :icon-class="item" class-name="icon" style="height: 25px;width: 16px;"/>
<span>{{ item }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
- 搜索区域:带清除功能的输入框,支持实时过滤
- 图标展示区域:网格布局的图标列表,每个图标包含图形和名称

数据状态管理:
javascript
const iconName = ref(''); // 搜索关键词
const iconList = ref(icons); // 过滤后的图标列表
const icons = require('./requireIcons'); // 所有可用图标
核心功能实现:
图标过滤逻辑:
javascript
function filterIcons() {
iconList.value = icons
if (iconName.value) {
iconList.value = icons.filter(item => item.indexOf(iconName.value) !== -1)
}
}
- 每次过滤先重置为完整图标列表
- 输入非空时进行字符串包含判断
- 使用简单的
indexOf方法实现模糊匹配
图标选择逻辑:
javascript
function selectedIcon(name) {
emit('selected', name) // 向父组件发送选中事件
document.body.click() // 触发全局点击事件,用于关闭弹窗等
}
- 通过事件通知父组件选中的图标名称
- 模拟全局点击事件,通常用于关闭弹窗或下拉菜单

重置功能:
javascript
function reset() {
iconName.value = '' // 清空搜索关键词
iconList.value = icons // 重置图标列表
}
defineExpose({
reset // 暴露重置方法供父组件调用
})
- 清空搜索状态
- 恢复完整的图标列表
- 通过
defineExpose暴露方法供父组件调用
样式设计:
css
<style lang='scss' scoped>
.icon-body {
width: 100%;
padding: 10px;
.icon-search {
position: relative;
margin-bottom: 5px;
}
.icon-list {
height: 200px;
overflow: auto;
.list-container {
display: flex;
flex-wrap: wrap;
.icon-item-wrapper {
width: calc(100% / 3);
height: 25px;
line-height: 25px;
cursor: pointer;
display: flex;
.icon-item {
display: flex;
max-width: 100%;
height: 100%;
padding: 0 5px;
&:hover {
background: #ececec;
border-radius: 5px;
}
.icon {
flex-shrink: 0;
}
span {
display: inline-block;
vertical-align: -0.15em;
fill: currentColor;
padding-left: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.icon-item.active {
background: #ececec;
border-radius: 5px;
}
}
}
}
}
</style>
- 网格布局:使用 Flexbox 实现响应式网格布局
- 固定高度滚动:限制列表高度,超出显示滚动条
- 悬停效果:鼠标悬停时显示灰色背景
- 文本省略:长图标名使用省略号显示
- 激活状态:选中项有明显的视觉反馈

图表列表:
javascript
import icons from './requireIcons'
IconSelect\requireIcons.js
收集和导出项目中所有 SVG 图标文件名
图标数组初始化:
javascript
let icons = []
动态导入 SVG 文件:
javascript
const modules = import.meta.glob('./../../assets/icons/svg/*.svg');
提取文件名:
javascript
for (const path in modules) {
const p = path.split('assets/icons/svg/')[1].split('.svg')[0];
icons.push(p);
}
导出图标数组:
javascript
export default icons
在src\views\system\menu\index.vue 处使用

iFrame\index.vue
封装了 iframe 元素的 Vue 组件
模板结构:
javascript
<template>
<div v-loading="loading" :style="'height:' + height">
<iframe
:src="url"
frameborder="no"
style="width: 100%; height: 100%"
scrolling="auto" />
</div>
</template>
- 外层容器:带有加载状态和动态高度设置
- iframe 元素:占据容器 100% 大小,无边框,自动滚动
核心功能实现:
高度自适应计算:
javascript
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
window.onresize = function temp() {
height.value = document.documentElement.clientHeight - 94.5 + "px;";
};
加载状态管理:
javascript
const loading = ref(true)
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 300);
});
URL 计算属性:
javascript
const url = computed(() => props.src)
- 响应式更新:当 src 属性变化时自动更新 iframe 的 src
- 只读保护:防止直接修改计算属性
脚本部分:
javascript
<script setup>
const props = defineProps({
src: {
type: String,
required: true
}
})
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
const loading = ref(true)
const url = computed(() => props.src)
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 300);
window.onresize = function temp() {
height.value = document.documentElement.clientHeight - 94.5 + "px;";
};
})
</script>
在

处使用

ImagePreview\index.vue
图片预览组件。
模板结构:
html
<template>
<el-image
:src="`${realSrc}`"
fit="cover"
:style="`width:${realWidth};height:${realHeight};`"
:preview-src-list="realSrcList"
preview-teleported
>
<template #error>
<div class="image-slot">
<el-icon><picture-filled /></el-icon>
</div>
</template>
</el-image>
</template>
核心功能实现:
图片源处理:
javascript
const realSrc = computed(() => {
if (!props.src) {
return;
}
let real_src = props.src.split(",")[0];
return real_src;
});
const realSrcList = computed(() => {
if (!props.src) {
return;
}
let real_src_list = props.src.split(",");
let srcList = [];
real_src_list.forEach(item => {
return srcList.push(item);
});
return srcList;
});
- 支持多图源:使用逗号分隔多个图片 URL
- 单图显示 :
realSrc获取第一张图片作为主显示 - 预览列表 :
realSrcList处理所有图片作为预览列表 - 错误处理:当 src 为空时返回 undefined
尺寸处理:
javascript
const realWidth = computed(() =>
typeof props.width == "string" ? props.width : `${props.width}px`
);
const realHeight = computed(() =>
typeof props.height == "string" ? props.height : `${props.height}px`
);
- 单位兼容:支持字符串(带单位)和数字(自动加 px)两种输入
- 自动转换:数字类型自动转换为带 px 的字符串
- 灵活性:用户可以传入 100、"100px"、"100%" 等不同格式
样式设计:
css
.el-image {
border-radius: 5px;
background-color: #ebeef5;
box-shadow: 0 0 5px 1px #ccc;
:deep(.el-image__inner) {
transition: all 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: #909399;
font-size: 30px;
}
}
- 悬停效果:鼠标悬停时图片放大,增强交互感
- 错误状态:加载失败时显示居中的图标提示
- 视觉美化:圆角、阴影、背景色提升视觉效果
在src\views\system\oss\index.vue 处使用
ImageUpload\index.vue
模板结构:
html
<template>
<div class="component-upload-image">
<el-upload
multiple
:action="uploadImgUrl"
list-type="picture-card"
:on-success="handleUploadSuccess"
:before-upload="handleBeforeUpload"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
ref="imageUpload"
:before-remove="handleDelete"
:show-file-list="true"
:headers="headers"
:file-list="fileList"
:on-preview="handlePictureCardPreview"
:class="{ hide: fileList.length >= limit }"
>
<el-icon class="avatar-uploader-icon"><plus /></el-icon>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip">
请上传
<template v-if="fileSize">
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
</template>
<template v-if="fileType">
格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
</template>
的文件
</div>
<el-dialog
v-model="dialogVisible"
title="预览"
width="800px"
append-to-body
>
<img
:src="dialogImageUrl"
style="display: block; max-width: 100%; margin: 0 auto"
/>
</el-dialog>
</div>
</template>
属性设计:
javascript
const props = defineProps({
modelValue: [String, Object, Array],
// 图片数量限制
limit: {
type: Number,
default: 5,
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 5,
},
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: {
type: Array,
default: () => ["png", "jpg", "jpeg"],
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
},
});
核心功能实现:
图片上传验证:
javascript
// 上传前loading加载
function handleBeforeUpload(file) {
let isImg = false;
if (props.fileType.length) {
let fileExtension = "";
if (file.name.lastIndexOf(".") > -1) {
fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1);
}
isImg = props.fileType.some((type) => {
if (file.type.indexOf(type) > -1) return true;
if (fileExtension && fileExtension.indexOf(type) > -1) return true;
return false;
});
} else {
isImg = file.type.indexOf("image") > -1;
}
if (!isImg) {
proxy.$modal.msgError(
`文件格式不正确, 请上传${props.fileType.join("/")}图片格式文件!`
);
return false;
}
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy.$modal.loading("正在上传图片,请稍候...");
number.value++;
}
- 类型验证:同时检查 MIME 类型和文件扩展名
- 大小验证:计算文件大小,确保不超过限制
- 用户反馈:提供明确的错误信息

文件列表管理:
javascript
watch(() => props.modelValue, async val => {
if (val) {
// 首先将值转为数组
let list;
if (Array.isArray(val)) {
list = val;
} else {
// 如果是字符串(图片ID),从服务器获取图片信息
await listByIds(val).then(res => {
list = res.data;
})
}
// 然后将数组转为对象数组
fileList.value = list.map(item => {
// 字符串回显处理 如果此处存的是url可直接回显 如果存的是id需要调用接口查出来
if (typeof item === "string") {
item = { name: item, url: item };
} else {
// 此处name使用ossId 防止删除出现重名
item = { name: item.ossId, url: item.url, ossId: item.ossId };
}
return item;
});
} else {
fileList.value = [];
}
}, { deep: true, immediate: true });
图片删除功能:
javascript
function handleDelete(file) {
const findex = fileList.value.map(f => f.name).indexOf(file.name);
if (findex > -1 && uploadList.value.length === number.value) {
let ossId = fileList.value[findex].ossId;
delOss(ossId); // 调用API删除服务器图片
fileList.value.splice(findex, 1); // 从列表中移除
emit("update:modelValue", listToString(fileList.value)); // 更新v-model
return false;
}
}
- 前后端同步:先删除服务器图片,再更新前端列表
- 状态同步:删除后立即更新 v-model
图片预览功能:
javascript
// 预览
function handlePictureCardPreview(file) {
dialogImageUrl.value = file.url;
dialogVisible.value = true;
}
- 设置预览图片 URL
- 打开预览对话框
数据格式转换:
javascript
// 对象转成指定字符串分隔
function listToString(list, separator) {
let strs = "";
separator = separator || ",";
for (let i in list) {
if(undefined !== list[i].ossId && list[i].url.indexOf("blob:") !== 0) {
strs += list[i].ossId + separator;
}
}
return strs != "" ? strs.substr(0, strs.length - 1) : "";
}
样式设计:
css
<style scoped lang="scss">
// .el-upload--picture-card 控制加号部分
:deep(.hide .el-upload--picture-card) {
display: none;
}
</style>
Pagination\index.vue
基于 Element Plus 的分页组件封装
模板结构:
html
<template>
<div :class="{ 'hidden': hidden }" class="pagination-container">
<el-pagination
:background="background"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
- 外层容器:带样式和隐藏/显示控制
- el-pagination 元素:核心分页器,配置了多种属性和事件

属性设计:
javascript
const props = defineProps({
total: {
required: true,
type: Number
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
pageSizes: {
type: Array,
default() {
return [10, 20, 30, 50]
}
},
// 移动端页码按钮的数量端默认值5
pagerCount: {
type: Number,
default: document.body.clientWidth < 992 ? 5 : 7
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
background: {
type: Boolean,
default: true
},
autoScroll: {
type: Boolean,
default: true
},
hidden: {
type: Boolean,
default: false
}
})
- 必需参数:总记录数
- 可配置参数:当前页码、每页数量、布局等
- 响应式设计:根据屏幕宽度自动调整页码按钮数量
- 用户体验:自动滚动功能
计算属性:
javascript
const currentPage = computed({
get() {
return props.page
},
set(val) {
emit('update:page', val)
}
})
const pageSize = computed({
get() {
return props.limit
},
set(val){
emit('update:limit', val)
}
})
- getter:从 props 获取当前值
- setter:通过事件通知父组件更新值
- 实现 v-model 双向绑定:支持 .sync 修饰符
核心功能实现:
页码变化处理:
javascript
function handleCurrentChange(val) {
emit('pagination', { page: val, limit: pageSize.value })
if (props.autoScroll) {
scrollTo(0, 800)
}
}
- 事件通知:发送包含页码和每页数量的分页信息对象
- 自动滚动:可选的滚动到页面顶部功能,提升用户体验
每页数量变化处理:
javascript
function handleSizeChange(val) {
// 检查当前页码是否超出新的总页数范围
if (currentPage.value * val > props.total) {
currentPage.value = 1
}
emit('pagination', { page: currentPage.value, limit: val })
if (props.autoScroll) {
scrollTo(0, 800)
}
}
- 边界检查:确保当前页码在新的分页范围内
- 自动重置:超出范围时自动回到第一页
- 事件通知:发送更新后的分页信息
样式设计:
css
<style scoped>
.pagination-container {
background: #fff;
padding: 32px 16px;
}
.pagination-container.hidden {
display: none;
}
</style>
在

处使用

ParentView\index.vue
路由视图容器
ParentView 作为一个中间层,允许子路由在它的 router-view 中渲染。
RightToolbar\index.vue
模板结构:
html
<template>
<div class="top-right-btn" :style="style">
<el-row>
<el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search">
<el-button circle icon="Search" @click="toggleSearch()" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="刷新" placement="top">
<el-button circle icon="Refresh" @click="refresh()" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="columns">
<el-button circle icon="Menu" @click="showColumn()" />
</el-tooltip>
</el-row>
<el-dialog :title="title" v-model="open" append-to-body>
<el-transfer
:titles="['显示', '隐藏']"
v-model="value"
:data="columns"
@change="dataChange"
></el-transfer>
</el-dialog>
</div>
</template>
- 工具栏按钮组:包含三个圆形按钮,每个按钮都有提示文本
- 列显隐设置对话框:使用 Transfer 穿梭框组件管理列的显示/隐藏

属性设计:
javascript
const props = defineProps({
showSearch: {
type: Boolean,
default: true,
},
columns: {
type: Array,
},
search: {
type: Boolean,
default: true,
},
gutter: {
type: Number,
default: 10,
},
})
- 搜索控制:通过 showSearch 属性控制搜索区域的显示/隐藏
- 列管理:通过 columns 属性传入表格列配置
- 按钮控制:通过 search 属性控制搜索按钮的显示
- 样式调整:通过 gutter 属性调整按钮间距
核心功能实现:
搜索区域显示/隐藏:
javascript
// 搜索
function toggleSearch() {
emits("update:showSearch", !props.showSearch);
}
- 双向绑定:使用 update:showSearch 事件实现 v-model 双向绑定
- 状态切换:简单地将当前状态取反
表格数据刷新:
javascript
// 刷新
function refresh() {
emits("queryTable");
}
- 事件通知:触发 queryTable 事件,由父组件处理具体刷新逻辑
- 职责分离:组件本身不处理数据刷新,只负责通知

列显示/隐藏管理:
javascript
// 右侧列表元素变化
function dataChange(data) {
for (let item in props.columns) {
const key = props.columns[item].key;
props.columns[item].visible = !data.includes(key);
}
}
// 打开显隐列对话框
function showColumn() {
open.value = true;
}
// 显隐列初始默认隐藏列
for (let item in props.columns) {
if (props.columns[item].visible === false) {
value.value.push(parseInt(item));
}
}
- 初始状态设置:遍历 columns 数组,将默认隐藏的列添加到 value 数组
- 状态变更处理:根据 Transfer 组件的变化更新列的 visible 属性
- 对话框控制:通过 showColumn 方法打开列设置对话框

样式计算:
javascript
const style = computed(() => {
const ret = {};
if (props.gutter) {
ret.marginRight = `${props.gutter / 2}px`;
}
return ret;
});
- 响应式边距:根据 gutter 属性动态计算右侧边距
- 样式封装:将样式逻辑封装在组件内部
样式设计:
javascript
:deep(.el-transfer__button) {
border-radius: 50%;
display: block;
margin-left: 0px;
}
:deep(.el-transfer__button:first-child) {
margin-bottom: 10px;
}
.my-el-transfer {
text-align: center;
}
在

处使用

RuoYi\Doc\index.vue
快捷访问组件。
模板结构:
html
<template>
<div>
<svg-icon icon-class="question" @click="goto" />
</div>
</template>
脚本部分:
html
<script setup>
const url = ref('https://gitee.com/JavaLionLi/RuoYi-Vue-Plus/wikis/pages');
function goto() {
window.open(url.value)
}
</script>
- 文档 URL:硬编码的文档链接,指向 RuoYi-Vue-Plus 项目的 Wiki 页面
- 导航函数:点击时在新窗口打开文档链接
RuoYi\Git\index.vue
模板结构:
html
<template>
<div>
<svg-icon icon-class="github" @click="goto" />
</div>
</template>
脚本部分:
html
<script setup>
const url = ref('https://gitee.com/JavaLionLi/RuoYi-Vue-Plus');
function goto() {
window.open(url.value)
}
</script>
- 仓库 URL:硬编码的项目链接,指向 RuoYi-Vue-Plus 项目的 Gitee 仓库
- 导航函数:点击时在新窗口打开仓库链接
Screenfull\index.vue
全屏切换功能组件
模板结构:
html
<template>
<div>
<svg-icon :icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'" @click="toggle" />
</div>
</template>
脚本部分:
html
<script setup>
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, enter, exit, toggle } = useFullscreen();
</script>
- 导入 VueUse :引入
useFullscreen功能 - 解构获取 :从
useFullscreen中获取响应式状态和方法isFullscreen:响应式的全屏状态enter:进入全屏的方法exit:退出全屏的方法toggle:切换全屏状态的方法
样式部分:
html
<style lang='scss' scoped>
.screenfull-svg {
display: inline-block;
cursor: pointer;
fill: #5a5e66;
width: 20px;
height: 20px;
vertical-align: 10px;
}
</style>
在src\layout\components\Navbar.vue 处使用

SizeSelect\index.vue
模板结构:
html
<template>
<div>
<el-dropdown trigger="click" @command="handleSetSize">
<div class="size-icon--style">
<svg-icon class-name="size-icon" icon-class="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size === item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
- 下拉菜单 :使用 Element Plus 的
el-dropdown组件 - 触发器:点击时显示下拉菜单的图标区域
- 选项列表:包含三种尺寸选项的下拉菜单项
核心功能实现:
状态管理集成:
javascript
import useAppStore from "@/store/modules/app";
const appStore = useAppStore();
const size = computed(() => appStore.size);
- 导入 store:获取应用全局状态管理
- 响应式状态:通过计算属性获取当前尺寸设置
- 状态同步:当 store 中的尺寸变化时,组件自动更新
尺寸变更处理:
javascript
function handleSetSize(size) {
proxy.$modal.loading("正在设置布局大小,请稍候...");
appStore.setSize(size);
setTimeout("window.location.reload()", 1000);
}
- 加载提示:显示加载状态提示用户正在处理
- 状态更新 :调用 store 的
setSize方法更新全局尺寸 - 页面刷新:1秒后刷新页面以应用新尺寸
尺寸选项配置:
javascript
const sizeOptions = ref([
{ label: "较大", value: "large" },
{ label: "默认", value: "default" },
{ label: "稍小", value: "small" },
]);
- large:较大尺寸
- default:默认尺寸
- small:较小尺寸
设计:
css
.size-icon--style {
font-size: 18px;
line-height: 50px;
padding-right: 7px;
}
在src\layout\components\Navbar.vue 处使用

SvgIcon\index.vue
模板结构:
html
<template>
<svg :class="svgClass" aria-hidden="true">
<use :xlink:href="iconName" :fill="color" />
</svg>
</template>
脚本部分:
html
<script>
export default defineComponent({
props: {
iconClass: {
type: String,
required: true
},
className: {
type: String,
default: ''
},
color: {
type: String,
default: ''
},
},
setup(props) {
return {
iconName: computed(() => `#icon-${props.iconClass}`),
svgClass: computed(() => {
if (props.className) {
return `svg-icon ${props.className}`
}
return 'svg-icon'
})
}
}
})
</script>
- 属性定义 :定义了三个属性,其中
iconClass是必需的 - 计算属性 :
iconName:生成 SVG 引用 ID,格式为#icon-${iconClass}svgClass:生成 SVG 元素的类名,基础类为svg-icon,可附加自定义类名
样式部分:
html
<style scope lang="scss">
.sub-el-icon,
.nav-icon {
display: inline-block;
font-size: 15px;
margin-right: 12px;
position: relative;
}
.svg-icon {
width: 1em;
height: 1em;
position: relative;
fill: currentColor;
vertical-align: -2px;
}
</style>
SvgIcon\svgicon.js
导入语句:
javascript
import * as components from '@element-plus/icons-vue'
插件导出:
javascript
export default {
install: (app) => {
for (const key in components) {
const componentConfig = components[key];
app.component(componentConfig.name, componentConfig);
}
},
};
- 插件定义 :导出一个包含
install方法的对象,这是 Vue 插件的标准格式 - 组件注册 :在
install方法中遍历所有导入的组件并注册到 Vue 应用中
虽然文件名包含 "svgicon",但这个文件与 SvgIcon/index.vue 组件有不同的用途:
| 特性 | SvgIcon/index.vue | SvgIcon/svgicon.js |
|---|---|---|
| 用途 | 自定义SVG图标显示 | 注册Element Plus图标 |
| 来源 | 项目自定义 | Element Plus图标库 |
| 实现 | SVG的元素 | Vue组件注册 |
| 输出 | 渲染SVG图标 | 全局注册图标组件 |
它们可配合使用:一个应用中可以同时使用自定义 SVG 图标(SvgIcon/index.vue)和 Element Plus 预定义图标(通过 svgicon.js 注册)。
在src\main.js 挂载,在src\views\system\menu\index.vue 处引用
TopNav\index.vue
模板结构:
html
<template>
<el-menu
:default-active="activeMenu"
mode="horizontal"
@select="handleSelect"
:ellipsis="false"
>
<template v-for="(item, index) in topMenus">
<el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber">
<svg-icon
v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
:icon-class="item.meta.icon"/>
{{ item.meta.title }}
</el-menu-item>
</template>
<!-- 顶部菜单超出数量折叠 -->
<el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
<template #title>更多菜单</template>
<template v-for="(item, index) in topMenus">
<el-menu-item
:index="item.path"
:key="index"
v-if="index >= visibleNumber">
<svg-icon
v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
:icon-class="item.meta.icon"/>
{{ item.meta.title }}
</el-menu-item>
</template>
</el-sub-menu>
</el-menu>
</template>
核心功能实现:
响应式菜单显示:
javascript
// 顶部栏初始数
const visibleNumber = ref(null);
function setVisibleNumber() {
const width = document.body.getBoundingClientRect().width / 3;
visibleNumber.value = parseInt(width / 85);
}
onMounted(() => {
window.addEventListener('resize', setVisibleNumber)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', setVisibleNumber)
})
- 计算显示数量:根据屏幕宽度计算可显示的菜单项数量(每85像素一个菜单项)
- 事件监听:监听窗口大小变化,动态调整显示数量
- 自动清理:组件卸载时移除事件监听器
菜单数据处理:
javascript
// 顶部显示菜单
const topMenus = computed(() => {
let topMenus = [];
routers.value.map((menu) => {
if (menu.hidden !== true) {
// 兼容顶部栏一级菜单内部跳转
if (menu.path === "/") {
topMenus.push(menu.children[0]);
} else {
topMenus.push(menu);
}
}
})
return topMenus;
})
// 设置子路由
const childrenMenus = computed(() => {
let childrenMenus = [];
routers.value.map((router) => {
for (let item in router.children) {
if (router.children[item].parentPath === undefined) {
if(router.path === "/") {
router.children[item].path = "/" + router.children[item].path;
} else {
if(!isHttp(router.children[item].path)) {
router.children[item].path = router.path + "/" + router.children[item].path;
}
}
router.children[item].parentPath = router.path;
}
childrenMenus.push(router.children[item]);
}
})
return constantRoutes.concat(childrenMenus);
})
- 过滤隐藏菜单 :排除
hidden属性为true的菜单 - 处理根路径特殊逻辑:将根路径下的第一个子菜单作为顶级菜单
- 路径规范化:为子菜单项构建完整路径
- 父子关联:设置子菜单的父路径,便于后续查找
激活菜单判断:
javascript
// 默认激活的菜单
const activeMenu = computed(() => {
const path = route.path;
let activePath = path;
if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
const tmpPath = path.substring(1, path.length);
activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"));
if (!route.meta.link) {
appStore.toggleSideBarHide(false);
}
} else if(!route.children) {
activePath = path;
appStore.toggleSideBarHide(true);
}
activeRoutes(activePath);
return activePath;
})
- 路径解析:从当前路径提取一级路径作为激活菜单
- 特殊页面处理:对于特殊页面(如用户资料页),隐藏侧边栏
- 联动控制:根据菜单状态控制侧边栏显示/隐藏
菜单选择处理:
javascript
function handleSelect(key, keyPath) {
currentIndex.value = key;
const route = routers.value.find(item => item.path === key);
if (isHttp(key)) {
// http(s):// 路径新窗口打开
window.open(key, "_blank");
} else if (!route || !route.children) {
// 没有子路由路径内部打开
const routeMenu = childrenMenus.value.find(item => item.path === key);
if (routeMenu && routeMenu.query) {
let query = JSON.parse(routeMenu.query);
router.push({ path: key, query: query });
} else {
router.push({ path: key });
}
appStore.toggleSideBarHide(true);
} else {
// 显示左侧联动菜单
activeRoutes(key);
appStore.toggleSideBarHide(false);
}
}
- 外部链接处理:识别并处理 http(s) 外部链接,在新窗口打开
- 无子菜单路由:直接导航到目标路由,隐藏侧边栏
- 有子菜单路由:激活对应的侧边栏菜单,显示侧边栏
- 查询参数处理:解析并附加路由查询参数
联动菜单激活:
javascript
function activeRoutes(key) {
let routes = [];
if (childrenMenus.value && childrenMenus.value.length > 0) {
childrenMenus.value.map((item) => {
if (key == item.parentPath || (key == "index" && "" == item.path)) {
routes.push(item);
}
});
}
if(routes.length > 0) {
permissionStore.setSidebarRouters(routes);
} else {
appStore.toggleSideBarHide(true);
}
return routes;
}
- 子菜单筛选:根据父路径筛选对应的子菜单项
- 侧边栏更新:将筛选后的菜单设置为侧边栏路由
- 空菜单处理:如果没有子菜单,隐藏侧边栏
样式设计:
css
.topmenu-container.el-menu--horizontal > .el-menu-item {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #999093 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
border-bottom: 2px solid #{'var(--theme)'} !important;
color: #303133;
}
/* sub-menu item */
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #999093 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
/* 背景色隐藏 */
.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
background-color: #ffffff !important;
}
/* 图标右间距 */
.topmenu-container .svg-icon {
margin-right: 4px;
}
/* topmenu more arrow */
.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
position: static;
vertical-align: middle;
margin-left: 8px;
margin-top: 0px;
}
在src\layout\components\Navbar.vue 处使用

TreeSelect\index.vue
模板结构:
html
<template>
<div class="el-tree-select">
<el-select
style="width: 100%"
v-model="valueId"
ref="treeSelect"
:filterable="true"
:clearable="true"
@clear="clearHandle"
:filter-method="selectFilterData"
:placeholder="placeholder"
>
<el-option :value="valueId" :label="valueTitle">
<el-tree
id="tree-option"
ref="selectTree"
:accordion="accordion"
:data="options"
:props="objMap"
:node-key="objMap.value"
:expand-on-click-node="false"
:default-expanded-keys="defaultExpandedKey"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
></el-tree>
</el-option>
</el-select>
</div>
</template>
- 外层选择器 :使用
el-select作为容器 - 内层树形结构 :将
el-tree嵌入到el-option中 - 自定义过滤:实现了树形结构的过滤功能

核心功能实现:
双向绑定实现:
javascript
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val)
}
});
- 计算属性:使用计算属性实现 getter/setter
- 值同步:通过 emit 触发 update:value 事件更新父组件的值
- 解耦设计 :组件内部使用
valueId,与父组件的value解耦
节点选择处理:
javascript
function handleNodeClick(node) {
valueTitle.value = node[props.objMap.label]
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
proxy.$refs.treeSelect.blur()
selectFilterData('')
}
- 更新显示值:设置选中节点的标签文本
- 更新实际值:设置选中节点的 ID
- 清空展开键:重置默认展开的节点
- 关闭下拉框:选择后自动关闭下拉框
- 清空过滤:清除过滤条件,恢复完整树形结构
树形过滤实现:
javascript
function selectFilterData(val) {
proxy.$refs.selectTree.filter(val)
}
function filterNode(value, data) {
if (!value) return true
return data[props.objMap['label']].indexOf(value) !== -1
}
- 过滤方法 :
selectFilterData调用树组件的 filter 方法 - 节点过滤 :
filterNode判断节点标签是否包含过滤文本 - 递归过滤:树组件内部递归调用此方法过滤所有节点
初始化与状态同步:
javascript
function initHandle() {
nextTick(() => {
const selectedValue = valueId.value;
if(selectedValue !== null && typeof (selectedValue) !== 'undefined') {
const node = proxy.$refs.selectTree.getNode(selectedValue)
if (node) {
valueTitle.value = node.data[props.objMap.label]
proxy.$refs.selectTree.setCurrentKey(selectedValue) // 设置默认选中
defaultExpandedKey.value = [selectedValue] // 设置默认展开
}
} else {
clearHandle()
}
})
}
- DOM 更新后执行:使用 nextTick 确保组件完全渲染
- 节点查找:根据 ID 查找树节点
- 状态同步:同步选中节点的标签和展开状态
- 默认处理:没有选中值时执行清除操作
清除功能实现:
javascript
function clearHandle() {
valueTitle.value = ''
valueId.value = ''
defaultExpandedKey.value = [];
clearSelected()
}
function clearSelected() {
const allNode = document.querySelectorAll('#tree-option .el-tree-node')
allNode.forEach((element) => element.classList.remove('is-current'))
}
- 值重置:清空显示值和实际值
- 展开状态重置:清空默认展开的节点
- 选中状态清除:通过 DOM 操作清除所有节点的当前选中状态
样式设计:
css
@import "@/assets/styles/variables.module.scss";
.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
padding: 0;
background-color: #fff;
height: auto;
}
.el-select-dropdown__item.selected {
font-weight: normal;
}
ul li .el-tree .el-tree-node__content {
height: auto;
padding: 0 20px;
box-sizing: border-box;
}
:deep(.el-tree-node__content:hover),
:deep(.el-tree-node__content:active),
:deep(.is-current > div:first-child),
:deep(.el-tree-node__content:focus) {
background-color: mix(#fff, $--color-primary, 90%);
color: $--color-primary;
}
