熟悉RuoYi-Vue-Plus-前端 (1)

请求

前端通过 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/ - 若依框架特定组件

实现了一个动态的面包屑导航,显示当前页面的层级路径

模板结构:

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 富文本编辑器组件

Installation | VueQuill

模板结构:

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

模糊搜索

Fuse.js | Fuse.js

模板结构:

  • 点击触发搜索框显示/隐藏
  • 使用 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

全屏切换功能组件

useFullscreen | VueUse 中文网

模板结构:

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);
}
  1. 加载提示:显示加载状态提示用户正在处理
  2. 状态更新 :调用 store 的 setSize 方法更新全局尺寸
  3. 页面刷新: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;
}
相关推荐
调皮LE41 分钟前
前端 HTML 转 PDF
前端
23124_8042 分钟前
网络管理-1
运维·服务器·前端
PBitW43 分钟前
Electron 初体验
前端·electron·trae
D***M9761 小时前
WebSpoon9.0(KETTLE的WEB版本)编译 + tomcatdocker部署 + 远程调试教程
前端
南囝coding1 小时前
《独立开发者精选工具》第 023 期
前端·后端·开源
文心快码BaiduComate1 小时前
Agent如何重塑跨角色协作的AI提效新范式
前端·后端·程序员
梦6501 小时前
react日历组件
前端·javascript·react.js
网络点点滴1 小时前
Vue3路由params参数
前端·javascript·vue.js
hqk1 小时前
鸿蒙 ArkUI 从零到精通:基础语法全解析
android·前端·harmonyos