Browse Source

feat: 部分组件重构完成

kailong321200875 3 years ago
parent
commit
3d9622978d

+ 16 - 1
components.d.ts

@@ -6,12 +6,17 @@ declare module 'vue' {
   export interface GlobalComponents {
   export interface GlobalComponents {
     404: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Error/404.vue')['default']
     404: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Error/404.vue')['default']
     CountTo: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/CountTo/index.vue')['default']
     CountTo: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/CountTo/index.vue')['default']
+    Dialog: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Dialog/index.vue')['default']
     Echart: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Echart/index.vue')['default']
     Echart: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Echart/index.vue')['default']
+    Editor: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Editor/index.vue')['default']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAlert: typeof import('element-plus/es')['ElAlert']
     ElBacktop: typeof import('element-plus/es')['ElBacktop']
     ElBacktop: typeof import('element-plus/es')['ElBacktop']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCard: typeof import('element-plus/es')['ElCard']
     ElCol: typeof import('element-plus/es')['ElCol']
     ElCol: typeof import('element-plus/es')['ElCol']
+    ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
     ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@@ -19,21 +24,31 @@ declare module 'vue' {
     ElForm: typeof import('element-plus/es')['ElForm']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElInput: typeof import('element-plus/es')['ElInput']
+    ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenu: typeof import('element-plus/es')['ElMenu']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
     ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
+    ElOption: typeof import('element-plus/es')['ElOption']
+    ElRadio: typeof import('element-plus/es')['ElRadio']
+    ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
+    ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
     ElRow: typeof import('element-plus/es')['ElRow']
     ElRow: typeof import('element-plus/es')['ElRow']
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
     ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
+    ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTabs: typeof import('element-plus/es')['ElTabs']
+    ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
+    ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     HelloWorld: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/HelloWorld.vue')['default']
     HelloWorld: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/HelloWorld.vue')['default']
     ParentView: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/ParentView/index.vue')['default']
     ParentView: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/ParentView/index.vue')['default']
     Preview: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Preview/index.vue')['default']
     Preview: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Preview/index.vue')['default']
+    Qrcode: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Qrcode/index.vue')['default']
     Redirect: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Redirect/index.vue')['default']
     Redirect: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Redirect/index.vue')['default']
+    Search: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/Search/index.vue')['default']
     SvgIcon: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/SvgIcon/index.vue')['default']
     SvgIcon: typeof import('C:/Users/Saber/Documents/HBuilderProjects/vue-element-plus-admin/src/components/SvgIcon/index.vue')['default']
   }
   }
 }
 }
 
 
-export {}
+export { }

+ 5 - 1
package.json

@@ -1,7 +1,7 @@
 {
 {
   "name": "vue-element-plus-admin",
   "name": "vue-element-plus-admin",
   "version": "1.0.0",
   "version": "1.0.0",
-  "description": "一套基于vue3、element-plus、typesScript、vite的后台集成方案",
+  "description": "一套基于vue3、element-plus、typesScript、vite2的后台集成方案",
   "author": "Archer <502431556@qq.com>",
   "author": "Archer <502431556@qq.com>",
   "private": false,
   "private": false,
   "scripts": {
   "scripts": {
@@ -26,9 +26,11 @@
     "@element-plus/icons": "^0.0.11",
     "@element-plus/icons": "^0.0.11",
     "@vueuse/core": "^6.5.3",
     "@vueuse/core": "^6.5.3",
     "axios": "^0.22.0",
     "axios": "^0.22.0",
+    "clipboard": "^2.0.8",
     "echarts": "^5.2.1",
     "echarts": "^5.2.1",
     "echarts-wordcloud": "^2.0.0",
     "echarts-wordcloud": "^2.0.0",
     "element-plus": "1.1.0-beta.20",
     "element-plus": "1.1.0-beta.20",
+    "highlight.js": "^11.2.0",
     "intro.js": "^4.2.2",
     "intro.js": "^4.2.2",
     "lodash-es": "^4.17.21",
     "lodash-es": "^4.17.21",
     "mockjs": "^1.1.0",
     "mockjs": "^1.1.0",
@@ -36,9 +38,11 @@
     "path-browserify": "^1.0.1",
     "path-browserify": "^1.0.1",
     "path-to-regexp": "^6.2.0",
     "path-to-regexp": "^6.2.0",
     "pinia": "^2.0.0-rc.13",
     "pinia": "^2.0.0-rc.13",
+    "qrcode": "^1.4.4",
     "qs": "^6.10.1",
     "qs": "^6.10.1",
     "vue": "^3.2.16",
     "vue": "^3.2.16",
     "vue-router": "^4.0.11",
     "vue-router": "^4.0.11",
+    "wangeditor": "^4.7.9",
     "web-storage-cache": "^1.1.1"
     "web-storage-cache": "^1.1.1"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 4 - 1
src/App.vue

@@ -1,11 +1,14 @@
 <template>
 <template>
-  <router-view class="app" :class="{ grey__mode: greyMode }" />
+  <el-config-provider :locale="zhCn">
+    <router-view class="app" :class="{ grey__mode: greyMode }" />
+  </el-config-provider>
 </template>
 </template>
 
 
 <script setup lang="ts" name="App">
 <script setup lang="ts" name="App">
 import { computed } from 'vue'
 import { computed } from 'vue'
 import { useAppStore } from '@/store/modules/app'
 import { useAppStore } from '@/store/modules/app'
 const appStore = useAppStore()
 const appStore = useAppStore()
+import zhCn from 'element-plus/lib/locale/lang/zh-cn'
 
 
 const greyMode = computed(() => appStore.getGreyMode)
 const greyMode = computed(() => appStore.getGreyMode)
 </script>
 </script>

BIN
src/assets/img/avatar.png


+ 3 - 1
src/components/CountTo/index.vue

@@ -15,7 +15,9 @@ const emit = defineEmits(['mounted', 'callback'])
 
 
 defineExpose({
 defineExpose({
   pauseResume,
   pauseResume,
-  reset
+  reset,
+  start,
+  pause
 })
 })
 
 
 const state = reactive<{
 const state = reactive<{

+ 193 - 0
src/components/Dialog/index.vue

@@ -0,0 +1,193 @@
+<template>
+  <el-dialog
+    ref="dialogRef"
+    v-bind="getBindValue"
+    :fullscreen="fullscreen"
+    destroy-on-close
+    lock-scroll
+    :close-on-click-modal="false"
+    top="10vh"
+  >
+    <template #title>
+      <slot name="title">
+        {{ title }}
+      </slot>
+      <svg-icon
+        v-if="showFullscreen"
+        :icon-class="fullscreen ? 'exit-fullscreen' : 'fullscreen'"
+        class-name="dialog__icon"
+        @click="toggleFull"
+      />
+    </template>
+
+    <!-- 弹窗内容 -->
+    <el-scrollbar
+      :class="
+        fullscreen && slots.footer
+          ? 'com-dialog__content--footer'
+          : fullscreen && !slots.footer
+          ? 'com-dialog__content--fullscreen'
+          : 'com-dialog__content'
+      "
+    >
+      <div class="content__wrap">
+        <slot></slot>
+      </div>
+    </el-scrollbar>
+
+    <template v-if="slots.footer" #footer>
+      <slot name="footer"></slot>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="Dialog">
+import { ref, computed, PropType, nextTick, unref, useAttrs, useSlots } from 'vue'
+import SvgIcon from '@/components/SvgIcon/index.vue'
+
+const slots = useSlots()
+
+const props = defineProps({
+  title: {
+    type: String as PropType<string>,
+    default: ''
+  },
+  // 是否显示全屏按钮
+  showFullscreen: {
+    type: Boolean as PropType<boolean>,
+    default: true
+  },
+  // 是否可以拖拽
+  draggable: {
+    type: Boolean as PropType<boolean>,
+    default: true
+  }
+})
+const dialogRef = ref<HTMLElement | null>(null)
+
+const fullscreen = ref<boolean>(false)
+
+const getBindValue = computed((): any => {
+  const delArr: string[] = ['showFullscreen', 'draggable']
+  const attrs = useAttrs()
+  const obj = { ...attrs, ...props }
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) {
+      delete obj[key]
+    }
+  }
+  return obj
+})
+
+function toggleFull(): void {
+  fullscreen.value = !fullscreen.value
+  // 全屏的时候需要重新定义left top
+  if (fullscreen.value && props.draggable) {
+    const dragDom = unref(dialogRef as any).$refs.dialogRef
+    dragDom.style.cssText += `;left:0px;top:0px;`
+  }
+}
+
+function initDraggable() {
+  nextTick(() => {
+    const dragDom = unref(dialogRef as any).$refs.dialogRef
+    const dialogHeaderEl = dragDom.querySelector('.el-dialog__header') as HTMLElement
+    dragDom.style.cssText += ';top:0px;'
+    dialogHeaderEl.style.cssText += ';cursor:move;user-select:none;'
+    dialogHeaderEl.onmousedown = (e) => {
+      const disX = e.clientX - dialogHeaderEl.offsetLeft
+      const disY = e.clientY - dialogHeaderEl.offsetTop
+
+      const dragDomWidth = dragDom.offsetWidth
+      const dragDomHeight = dragDom.offsetHeight
+
+      const screenWidth = document.body.clientWidth
+      const screenHeight = document.body.clientHeight
+
+      const minDragDomLeft = dragDom.offsetLeft
+      const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth
+
+      const minDragDomTop = dragDom.offsetTop
+      const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomHeight
+
+      const styleLeftStr = getComputedStyle(dragDom).left
+      const styleTopStr = getComputedStyle(dragDom).top
+      if (!styleLeftStr || !styleTopStr) return
+      let styleLeft: number
+      let styleTop: number
+
+      // Format may be "##%" or "##px"
+      if (styleLeftStr.includes('%')) {
+        styleLeft = +document.body.clientWidth * (+styleLeftStr.replace(/%/g, '') / 100)
+        styleTop = +document.body.clientHeight * (+styleTopStr.replace(/%/g, '') / 100)
+      } else {
+        styleLeft = +styleLeftStr.replace(/px/g, '')
+        styleTop = +styleTopStr.replace(/px/g, '')
+      }
+
+      document.onmousemove = (e) => {
+        let left = e.clientX - disX
+        let top = e.clientY - disY
+
+        // Handle edge cases
+        if (-left > minDragDomLeft) {
+          left = -minDragDomLeft
+        } else if (left > maxDragDomLeft) {
+          left = maxDragDomLeft
+        }
+        if (-top > minDragDomTop) {
+          top = -minDragDomTop
+        } else if (top > maxDragDomTop) {
+          top = maxDragDomTop
+        }
+
+        // Move current element
+        dragDom.style.cssText += `;left:${left + styleLeft}px;top:${top + styleTop}px;`
+      }
+
+      document.onmouseup = () => {
+        document.onmousemove = null
+        document.onmouseup = null
+      }
+    }
+  })
+}
+
+if (props.draggable) {
+  initDraggable()
+}
+</script>
+
+<style lang="less" scoped>
+.dialog__icon {
+  position: absolute;
+  top: 22px;
+  right: 45px;
+  font-size: 12px;
+  color: #909399;
+  cursor: pointer;
+  transition: color 0.2s;
+  &:hover {
+    color: #409eff;
+  }
+}
+.com-dialog__content {
+  .content__wrap {
+    padding-right: 10px;
+  }
+  :deep(.el-scrollbar__wrap) {
+    max-height: 600px; // 最大高度
+    overflow-x: hidden; // 隐藏横向滚动栏
+  }
+}
+.com-dialog__content--fullscreen {
+  :deep(.el-scrollbar__wrap) {
+    height: calc(~'100vh - 46px - 60px'); // 最大高度
+  }
+}
+.com-dialog__content--footer {
+  :deep(.el-scrollbar__wrap) {
+    max-height: calc(~'100vh - 46px - 60px - 70px'); // 最大高度
+  }
+}
+</style>

+ 215 - 0
src/components/Editor/index.vue

@@ -0,0 +1,215 @@
+<template>
+  <div ref="editorRef"></div>
+</template>
+
+<script setup lang="ts" name="Editor">
+import { PropType, watch, computed, onMounted, onBeforeUnmount, ref, unref } from 'vue'
+import E from 'wangeditor'
+import hljs from 'highlight.js' // 这个蠢货插件,9以上的版本都不支持IE。辣鸡!!
+import 'highlight.js/styles/monokai-sublime.css'
+import { oneOf } from '@/utils'
+import { EditorConfig } from './types'
+import { Message } from '_c/Message'
+
+const props = defineProps({
+  config: {
+    type: Object as PropType<EditorConfig>,
+    default: () => {
+      return {}
+    }
+  },
+  valueType: {
+    type: String as PropType<'html' | 'text'>,
+    default: 'html',
+    validator: (val: string) => {
+      return oneOf(val, ['html', 'text'])
+    }
+  },
+  value: {
+    type: String as PropType<string>,
+    default: ''
+  }
+})
+
+const emit = defineEmits(['change', 'focus', 'blur'])
+
+defineExpose({
+  getHtml,
+  getJSON,
+  getText
+})
+
+let editor: Nullable<E> = null
+const value = computed(() => props.value)
+const editorRef = ref<Nullable<HTMLElement>>(null)
+
+watch(
+  value,
+  (val: string) => {
+    if (editor) {
+      editor.txt.html(val)
+    }
+  },
+  {
+    immediate: true
+  }
+)
+
+function createdEditor() {
+  editor = new E(unref(editorRef.value) as HTMLElement)
+  initConfig()
+  editor.create()
+  editor.txt.html(value.value)
+}
+
+function initConfig() {
+  const config = props.config as EditorConfig
+  const editorRef = editor as E
+
+  // // 设置编辑区域高度为 500px
+  editorRef.config.height = config.height || 500
+
+  // // 设置zIndex
+  editorRef.config.zIndex = config.zIndex || 0
+
+  // // 设置 placeholder 提示文字
+  editorRef.config.placeholder = config.placeholder || '请输入文本'
+
+  // // 设置是否自动聚焦
+  editorRef.config.focus = config.focus || false
+
+  // 配置菜单
+  editorRef.config.menus = config.menus || [
+    'head',
+    'bold',
+    'fontSize',
+    'fontName',
+    'italic',
+    'underline',
+    'strikeThrough',
+    'indent',
+    'lineHeight',
+    'foreColor',
+    'backColor',
+    'link',
+    'list',
+    'justify',
+    'quote',
+    'emoticon',
+    'image',
+    'video',
+    'table',
+    'code',
+    'splitLine',
+    'undo',
+    'redo'
+  ]
+
+  // 配置颜色(文字颜色、背景色)
+  editorRef.config.colors = config.colors || ['#000000', '#eeece0', '#1c487f', '#4d80bf']
+
+  // 配置字体
+  editorRef.config.fontNames = config.fontNames || [
+    '黑体',
+    '仿宋',
+    '楷体',
+    '标楷体',
+    '华文仿宋',
+    '华文楷体',
+    '宋体',
+    '微软雅黑',
+    'Arial',
+    'Tahoma',
+    'Verdana',
+    'Times New Roman',
+    'Courier New'
+  ]
+
+  // 配置行高
+  editorRef.config.lineHeights = config.lineHeights || ['1', '1.15', '1.6', '2', '2.5', '3']
+
+  // // 代码高亮
+  editorRef.highlight = hljs
+
+  // // 配置全屏
+  editorRef.config.showFullScreen = config.showFullScreen || true
+
+  // 编辑器 customAlert 是对全局的alert做了统一处理,默认为 window.alert。
+  // 如觉得浏览器自带的alert体验不佳,可自定义 alert,以便于达到与自身项目统一的alert效果。
+  editorRef.config.customAlert =
+    config.customAlert ||
+    function (s: string, t: string) {
+      switch (t) {
+        case 'success':
+          Message.success(s)
+          break
+        case 'info':
+          Message.info(s)
+          break
+        case 'warning':
+          Message.warning(s)
+          break
+        case 'error':
+          Message.error(s)
+          break
+        default:
+          Message.info(s)
+          break
+      }
+    }
+
+  // 图片上传默认使用base64
+  editorRef.config.uploadImgShowBase64 = true
+
+  // 配置 onchange 回调函数
+  editorRef.config.onchange = (html: string) => {
+    const text = editorRef.txt.text()
+    emitFun(editor, props.valueType === 'html' ? html : text, 'change')
+  }
+  // 配置触发 onchange 的时间频率,默认为 200ms
+  editorRef.config.onchangeTimeout = config.onchangeTimeout || 1000
+
+  // 编辑区域 focus(聚焦)和 blur(失焦)时触发的回调函数。
+  editorRef.config.onblur = (html: string) => {
+    emitFun(editor, html, 'blur')
+  }
+  editorRef.config.onfocus = (html: string) => {
+    emitFun(editor, html, 'focus')
+  }
+}
+
+function emitFun(editor: any, _: string, type: 'change' | 'focus' | 'blur'): void {
+  if (editor) {
+    emit(type, props.valueType === 'html' ? (editor as E).txt.html() : (editor as E).txt.text())
+  }
+}
+
+function getHtml() {
+  if (editor) {
+    return (editor as E).txt.html()
+  }
+}
+
+function getText() {
+  if (editor) {
+    return (editor as E).txt.text()
+  }
+}
+
+function getJSON() {
+  if (editor) {
+    return (editor as E).txt.getJSON()
+  }
+}
+
+onMounted(() => {
+  createdEditor()
+})
+
+onBeforeUnmount(() => {
+  if (editor) {
+    ;(editor as E).destroy()
+    editor = null
+  }
+})
+</script>

+ 13 - 0
src/components/Editor/types.ts

@@ -0,0 +1,13 @@
+export interface EditorConfig {
+  height?: number // 富文本高度
+  zIndex?: number // 层级
+  placeholder?: string // 提示文字
+  focus?: boolean // 是否聚焦
+  onchangeTimeout?: number // 几秒监听一次变化
+  customAlert?: (s: string, t: string) => {} // 自定义提示
+  menus?: string[] // 按钮菜单
+  colors?: string[] // 颜色
+  fontNames?: string[] // 字体
+  lineHeights?: string[] // 行间距
+  showFullScreen?: boolean // 是否全屏
+}

+ 267 - 0
src/components/Qrcode/index.vue

@@ -0,0 +1,267 @@
+<template>
+  <div v-loading="loading" class="qrcode__wrap" :style="wrapStyle">
+    <component :is="tag" ref="wrapRef" @click="clickCode" />
+    <div v-if="disabled" class="disabled__wrap" @click="disabledClick">
+      <div>
+        <i class="el-icon-refresh-right"></i>
+        <div>{{ disabledText }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="Qrcode">
+import { PropType, nextTick, ref, watch, computed, unref } from 'vue'
+import type { LogoTypes } from './types'
+import QRCode from 'qrcode'
+import { QRCodeRenderersOptions } from 'qrcode'
+import { deepClone } from '@/utils'
+import { isString } from '@/utils/validate'
+const { toCanvas, toDataURL } = QRCode
+
+const props = defineProps({
+  // img 或者 canvas,img不支持logo嵌套
+  tag: {
+    type: String as PropType<'canvas' | 'img'>,
+    default: 'canvas',
+    validator: (v: string) => ['canvas', 'img'].includes(v)
+  },
+  // 二维码内容
+  text: {
+    type: [String, Array] as PropType<string | any[]>,
+    default: null
+  },
+  // qrcode.js配置项
+  options: {
+    type: Object as PropType<QRCodeRenderersOptions>,
+    default: () => {
+      return {}
+    }
+  },
+  // 宽度
+  width: {
+    type: Number as PropType<number>,
+    default: 200
+  },
+  // logo
+  logo: {
+    type: [String, Object] as PropType<Partial<LogoTypes> | string>,
+    default: ''
+  },
+  // 是否过期
+  disabled: {
+    type: Boolean as PropType<boolean>,
+    default: false
+  },
+  // 过期提示内容
+  disabledText: {
+    type: String as PropType<string>,
+    default: '二维码已失效'
+  }
+})
+const emit = defineEmits(['done', 'click', 'disabled-click'])
+const loading = ref<boolean>(true)
+const wrapRef = ref<HTMLCanvasElement | HTMLImageElement | null>(null)
+const renderText = computed(() => String(props.text))
+const wrapStyle = computed(() => {
+  return {
+    width: props.width + 'px',
+    height: props.width + 'px'
+  }
+})
+
+watch(
+  () => renderText.value,
+  (val) => {
+    if (!val) return
+    initQrcode()
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+// 初始化
+function initQrcode() {
+  nextTick(async () => {
+    const options = deepClone(props.options || {})
+    if (props.tag === 'canvas') {
+      // 容错率,默认对内容少的二维码采用高容错率,内容多的二维码采用低容错率
+      options.errorCorrectionLevel =
+        options.errorCorrectionLevel || getErrorCorrectionLevel(renderText.value)
+      getOriginWidth(renderText.value, options).then(async (_width) => {
+        options.scale = props.width === 0 ? undefined : (props.width / _width) * 4
+        const canvasRef: any = await toCanvas(unref(wrapRef as any), renderText.value, options)
+        if (props.logo) {
+          const url = await createLogoCode(canvasRef)
+          emit('done', url)
+          loading.value = false
+        } else {
+          emit('done', canvasRef.toDataURL())
+          loading.value = false
+        }
+      })
+    } else {
+      const url = await toDataURL(renderText.value, {
+        errorCorrectionLevel: 'H',
+        width: props.width,
+        ...options
+      })
+      unref(wrapRef as any).src = url
+      emit('done', url)
+      loading.value = false
+    }
+  })
+}
+
+// 生成logo
+function createLogoCode(canvasRef: HTMLCanvasElement) {
+  const canvasWidth = canvasRef.width
+  const logoOptions: LogoTypes = Object.assign(
+    {
+      logoSize: 0.15,
+      bgColor: '#ffffff',
+      borderSize: 0.05,
+      crossOrigin: 'anonymous',
+      borderRadius: 8,
+      logoRadius: 0
+    },
+    isString(props.logo) ? {} : props.logo
+  )
+  const {
+    logoSize = 0.15,
+    bgColor = '#ffffff',
+    borderSize = 0.05,
+    crossOrigin = 'anonymous',
+    borderRadius = 8,
+    logoRadius = 0
+  } = logoOptions
+  const logoSrc = isString(props.logo) ? props.logo : props.logo.src
+  const logoWidth = canvasWidth * logoSize
+  const logoXY = (canvasWidth * (1 - logoSize)) / 2
+  const logoBgWidth = canvasWidth * (logoSize + borderSize)
+  const logoBgXY = (canvasWidth * (1 - logoSize - borderSize)) / 2
+
+  const ctx = canvasRef.getContext('2d')
+  if (!ctx) return
+
+  // logo 底色
+  canvasRoundRect(ctx)(logoBgXY, logoBgXY, logoBgWidth, logoBgWidth, borderRadius)
+  ctx.fillStyle = bgColor
+  ctx.fill()
+
+  // logo
+  const image = new Image()
+  if (crossOrigin || logoRadius) {
+    image.setAttribute('crossOrigin', crossOrigin)
+  }
+  ;(image as any).src = logoSrc
+
+  // 使用image绘制可以避免某些跨域情况
+  const drawLogoWithImage = (image: HTMLImageElement) => {
+    ctx.drawImage(image, logoXY, logoXY, logoWidth, logoWidth)
+  }
+
+  // 使用canvas绘制以获得更多的功能
+  const drawLogoWithCanvas = (image: HTMLImageElement) => {
+    const canvasImage = document.createElement('canvas')
+    canvasImage.width = logoXY + logoWidth
+    canvasImage.height = logoXY + logoWidth
+    const imageCanvas = canvasImage.getContext('2d')
+    if (!imageCanvas || !ctx) return
+    imageCanvas.drawImage(image, logoXY, logoXY, logoWidth, logoWidth)
+
+    canvasRoundRect(ctx)(logoXY, logoXY, logoWidth, logoWidth, logoRadius)
+    if (!ctx) return
+    const fillStyle = ctx.createPattern(canvasImage, 'no-repeat')
+    if (fillStyle) {
+      ctx.fillStyle = fillStyle
+      ctx.fill()
+    }
+  }
+
+  // 将 logo绘制到 canvas上
+  return new Promise((resolve: any) => {
+    image.onload = () => {
+      logoRadius ? drawLogoWithCanvas(image) : drawLogoWithImage(image)
+      resolve(canvasRef.toDataURL())
+    }
+  })
+}
+
+// 得到原QrCode的大小,以便缩放得到正确的QrCode大小
+function getOriginWidth(content: string, options: QRCodeRenderersOptions) {
+  const _canvas = document.createElement('canvas')
+  return toCanvas(_canvas, content, options).then(() => _canvas.width)
+}
+
+// 对于内容少的QrCode,增大容错率
+function getErrorCorrectionLevel(content: string) {
+  if (content.length > 36) {
+    return 'M'
+  } else if (content.length > 16) {
+    return 'Q'
+  } else {
+    return 'H'
+  }
+}
+
+// 点击二维码
+function clickCode() {
+  emit('click')
+}
+
+// 失效点击事件
+function disabledClick() {
+  emit('disabled-click')
+}
+
+// copy来的方法,用于绘制圆角
+function canvasRoundRect(ctx: CanvasRenderingContext2D) {
+  return (x: number, y: number, w: number, h: number, r: number) => {
+    const minSize = Math.min(w, h)
+    if (r > minSize / 2) {
+      r = minSize / 2
+    }
+    ctx.beginPath()
+    ctx.moveTo(x + r, y)
+    ctx.arcTo(x + w, y, x + w, y + h, r)
+    ctx.arcTo(x + w, y + h, x, y + h, r)
+    ctx.arcTo(x, y + h, x, y, r)
+    ctx.arcTo(x, y, x + w, y, r)
+    ctx.closePath()
+    return ctx
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.qrcode__wrap {
+  display: inline-block;
+  position: relative;
+  .disabled__wrap {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    background: rgba(255, 255, 255, 0.95);
+    top: 0;
+    left: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    & > div {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      font-weight: bold;
+      i {
+        font-size: 30px;
+        margin-bottom: 10px;
+      }
+    }
+  }
+}
+</style>

+ 9 - 0
src/components/Qrcode/types.ts

@@ -0,0 +1,9 @@
+export interface LogoTypes {
+  src?: string
+  logoSize?: number
+  bgColor?: string
+  borderSize?: number
+  crossOrigin?: string
+  borderRadius?: number
+  logoRadius?: number
+}

+ 337 - 0
src/components/Search/index.vue

@@ -0,0 +1,337 @@
+<template>
+  <div :class="{ search__col: layout === 'right' }">
+    <el-row :gutter="20">
+      <el-col :span="layout === 'right' ? 22 : 24">
+        <el-form
+          ref="ruleForm"
+          inline
+          :model="formInline"
+          :rules="rules"
+          :label-width="labelWidth"
+          :label-position="labelPosition"
+          :hide-required-asterisk="hideRequiredAsterisk"
+          @submit.prevent
+        >
+          <el-form-item
+            v-for="(item, $index) in data"
+            :key="$index"
+            :label="item.label"
+            :prop="item.field"
+            :rules="item.rules"
+          >
+            <template v-if="item.itemType === 'switch'">
+              <el-switch
+                v-model="formInline[item.field]"
+                v-bind="{ ...getItemBindValue(item) }"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              />
+            </template>
+
+            <template v-if="item.itemType === 'input'">
+              <el-input
+                v-model="formInline[item.field]"
+                v-bind="{ ...getItemBindValue(item) }"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              />
+            </template>
+
+            <template v-if="item.itemType === 'select'">
+              <el-select
+                v-model="formInline[item.field]"
+                v-bind="{ ...getItemBindValue(item) }"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              >
+                <el-option
+                  v-for="v in item.options"
+                  :key="item.optionValue ? v[item.optionValue] : v.value"
+                  :value="item.optionValue ? v[item.optionValue] : v.value"
+                  :label="item.optionLabel ? v[item.optionLabel] : v.title"
+                />
+              </el-select>
+            </template>
+
+            <template v-if="item.itemType === 'radio'">
+              <el-radio-group
+                v-model="formInline[item.field]"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              >
+                <template v-if="item.radioType === 'radio'">
+                  <el-radio
+                    v-for="v in item.options"
+                    :key="item.optionValue ? v[item.optionValue] : v.value"
+                    v-bind="{ ...getItemBindValue(item) }"
+                    :label="item.optionValue ? v[item.optionValue] : v.value"
+                  >
+                    {{ item.optionLabel ? v[item.optionLabel] : v.label }}
+                  </el-radio>
+                </template>
+                <template v-else-if="item.radioType === 'button'">
+                  <el-radio-button
+                    v-for="v in item.options"
+                    :key="item.optionValue ? v[item.optionValue] : v.value"
+                    v-bind="{ ...getItemBindValue(item) }"
+                    :label="item.optionValue ? v[item.optionValue] : v.value"
+                  >
+                    {{ item.optionLabel ? v[item.optionLabel] : v.label }}
+                  </el-radio-button>
+                </template>
+              </el-radio-group>
+            </template>
+
+            <!-- element近期会新增treeSelect组件,所以不打算在自己维护一套。等待ing -->
+            <!-- <template v-if="item.itemType === 'treeSelect'">
+              <el-tree-select
+                v-model:value="formInline[item.field]"
+                :size="item.size"
+                :dropdown-style="item.dropdownStyle"
+                :tree-data="item.options"
+                :placeholder="item.placeholder"
+                :tree-checkable="item.treeCheckable"
+                :max-tag-count="item.maxTagCount"
+                :tree-default-expand-all="item.treeDefaultExpandAll"
+                :allow-clear="item.allowClear"
+                style="min-width: 201px;"
+                @change="((val) => {changeVal(val, item)})"
+              >
+                <template #title="{ title }">
+                  <span>{{ title }}</span>
+                </template>
+              </el-tree-select>
+            </template> -->
+
+            <template v-if="item.itemType === 'timePicker'">
+              <el-time-picker
+                v-model="formInline[item.field]"
+                v-bind="{ ...getItemBindValue(item) }"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              />
+            </template>
+
+            <template v-if="item.itemType === 'timeSelect'">
+              <el-time-select
+                v-model="formInline[item.field]"
+                v-bind="{ ...getItemBindValue(item) }"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              />
+            </template>
+
+            <template v-if="item.itemType === 'datePicker' || item.itemType === 'dateTimePicker'">
+              <el-date-picker
+                v-model="formInline[item.field]"
+                v-bind="{ ...getItemBindValue(item) }"
+                @change="
+                  (val) => {
+                    changeVal(val, item)
+                  }
+                "
+              />
+            </template>
+          </el-form-item>
+          <el-form-item v-if="data.length > 0 && layout === 'classic'">
+            <el-button type="primary" icon="el-icon-search" @click="submitForm"> 查询 </el-button>
+            <el-button v-if="showReset" icon="el-icon-refresh-right" @click="resetForm">
+              重置
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </el-col>
+      <el-col :span="layout === 'right' ? 2 : 24">
+        <div
+          v-if="data.length > 0 && (layout === 'bottom' || layout === 'right')"
+          class="search__bottom"
+          :class="{ 'search__bottom--col': layout === 'right' }"
+        >
+          <div class="search__bottom--button">
+            <el-button type="primary" icon="el-icon-search" @click="submitForm"> 查询 </el-button>
+          </div>
+          <div class="search__bottom--button">
+            <el-button
+              v-if="showReset"
+              :style="{
+                'margin-left': layout !== 'right' ? '15px' : '0',
+                'margin-top': layout === 'right' ? '27px' : '0'
+              }"
+              icon="el-icon-refresh-right"
+              @click="resetForm"
+            >
+              重置
+            </el-button>
+          </div>
+        </div>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts" name="Search">
+import { PropType, watch, ref, unref } from 'vue'
+import { deepClone } from '@/utils'
+
+const props = defineProps({
+  // 表单域标签的宽度,例如 '50px'。作为 Form 直接子元素的 form-item 会继承该值。支持 auto。
+  labelWidth: {
+    type: String as PropType<string>,
+    default: ''
+  },
+  labelPosition: {
+    type: String as PropType<'right' | 'left' | 'top'>,
+    default: 'right'
+  },
+  // 隐藏所有表单项的必选标记
+  hideRequiredAsterisk: {
+    type: Boolean as PropType<boolean>,
+    default: true
+  },
+  // 表单数据对象
+  data: {
+    type: Object as PropType<{ [key: string]: any }>,
+    default: () => {}
+  },
+  // 表单验证规则
+  rules: {
+    type: Object as PropType<{ [key: number]: any }>,
+    default: () => {
+      return {}
+    }
+  },
+  // 是否显示重置按钮
+  showReset: {
+    type: Boolean as PropType<boolean>,
+    default: true
+  },
+  // 是否显示导出按钮
+  showExport: {
+    type: Boolean as PropType<boolean>,
+    default: false
+  },
+  // 风格
+  layout: {
+    type: String as PropType<'classic' | 'bottom' | 'right'>,
+    default: 'classic'
+  }
+})
+
+const emit = defineEmits(['search-submit', 'reset-submit', 'change'])
+
+const ruleForm = ref<HTMLElement | null>(null)
+const formInline = ref<IObj>({})
+watch(
+  () => props.data,
+  (data) => {
+    initForm(data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+function getItemBindValue(item: any) {
+  const delArr: string[] = ['label', 'itemType', 'value', 'field']
+  const obj = deepClone(item)
+  for (const key in obj) {
+    if (delArr.indexOf(key) !== -1) {
+      delete obj[key]
+    }
+  }
+  return obj
+}
+
+function initForm(data: any): void {
+  for (const v of data) {
+    formInline.value[v.field] = formInline.value[v.field] || v.value
+  }
+}
+
+async function submitForm(): Promise<void> {
+  const form = unref(ruleForm) as any
+  if (!form) return
+  try {
+    form.validate((valid: boolean) => {
+      if (valid) {
+        emit('search-submit', unref(formInline))
+      } else {
+        console.log('error submit!!')
+        return false
+      }
+    })
+  } catch (err) {
+    console.log(err)
+  }
+}
+
+async function resetForm(): Promise<void> {
+  const form = unref(ruleForm) as any
+  if (!form) return
+  await form.resetFields()
+  emit('reset-submit', unref(formInline))
+}
+
+function changeVal(val: any, item: any): void {
+  if (item.onChange) {
+    emit('change', {
+      field: item.field,
+      value: val
+    })
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.ant-form-inline {
+  .ant-form-item {
+    min-height: 60px;
+  }
+  .ant-form-item-with-help {
+    margin-bottom: 0;
+  }
+}
+.search__bottom {
+  text-align: center;
+  padding-bottom: 20px;
+  .search__bottom--button {
+    display: inline-block;
+  }
+}
+.search__bottom--col {
+  padding-bottom: 0;
+  margin-top: 5px;
+  position: relative;
+  .search__bottom--button {
+    display: inline-block;
+  }
+}
+.search__bottom--col::before {
+  content: '';
+  width: 1px;
+  height: 100%;
+  border-left: 1px solid #d9d9d9;
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+</style>

+ 4 - 0
src/components/index.ts

@@ -1,6 +1,10 @@
 import type { App } from 'vue'
 import type { App } from 'vue'
 import SvgIcon from './SvgIcon/index.vue' // svg组件
 import SvgIcon from './SvgIcon/index.vue' // svg组件
+import ComSearch from './Search/index.vue' // search组件
+import ComDialog from './Dialog/index.vue' // dialog组件
 
 
 export function setupGlobCom(app: App<Element>): void {
 export function setupGlobCom(app: App<Element>): void {
   app.component('SvgIcon', SvgIcon)
   app.component('SvgIcon', SvgIcon)
+  app.component('ComSearch', ComSearch)
+  app.component('ComDialog', ComDialog)
 }
 }

+ 71 - 0
src/directives/clipboard/index.ts

@@ -0,0 +1,71 @@
+import Clipboard from 'clipboard'
+import { Directive, DirectiveBinding } from 'vue'
+import { Message } from '_c/Message'
+
+if (!Clipboard) {
+  throw new Error('you should npm install `clipboard` --save at first ')
+}
+
+export const clipboard: Directive = {
+  beforeMount(el: HTMLElement, binding: DirectiveBinding) {
+    createdClipboard(el, binding.arg, binding.value)
+  },
+  updated(el: HTMLElement | any, binding: DirectiveBinding) {
+    if (binding.arg === 'success') {
+      el._v_clipboard_success = binding.value
+    } else if (binding.arg === 'error') {
+      el._v_clipboard_error = binding.value
+    } else {
+      el._v_clipboard.text = function () {
+        return binding.value
+      }
+      el._v_clipboard.action = function () {
+        return 'copy'
+      }
+    }
+  },
+  unmounted(el: HTMLElement | any, binding: DirectiveBinding) {
+    if (binding.arg === 'success') {
+      delete el._v_clipboard_success
+    } else if (binding.arg === 'error') {
+      delete el._v_clipboard_error
+    } else {
+      el._v_clipboard.destroy()
+      delete el._v_clipboard
+    }
+  }
+}
+
+function createdClipboard(el: HTMLElement | any, arg: string | undefined, value: any) {
+  if (arg === 'success') {
+    el._v_clipboard_success = value
+  } else if (arg === 'error') {
+    el._v_clipboard_error = value
+  } else {
+    const clipboard = new Clipboard(el, {
+      text() {
+        return value
+      },
+      action() {
+        return 'copy'
+      }
+    })
+    clipboard.on('success', (e) => {
+      const callback = el._v_clipboard_success
+      if (callback) {
+        callback(e)
+      } else {
+        Message.success('复制成功')
+      }
+    })
+    clipboard.on('error', (e) => {
+      const callback = el._v_clipboard_error
+      if (callback) {
+        callback(e)
+      } else {
+        Message.success('复制失败')
+      }
+    })
+    el._v_clipboard = clipboard
+  }
+}

+ 7 - 0
src/directives/index.ts

@@ -0,0 +1,7 @@
+import type { App } from 'vue'
+
+import { clipboard } from './clipboard'
+
+export function setupDirectives(app: App) {
+  app.directive('clipboard', clipboard)
+}

+ 1 - 1
src/layout/components/UserInfo/index.vue

@@ -27,7 +27,7 @@ const tagsViewStore = useTagsViewStore()
 import { useCache } from '@/hooks/web/useCache'
 import { useCache } from '@/hooks/web/useCache'
 const { wsCache } = useCache()
 const { wsCache } = useCache()
 // @ts-ignore
 // @ts-ignore
-import avatarImg from '@/assets/img/avatar.gif'
+import avatarImg from '@/assets/img/avatar.png'
 
 
 const { replace, push } = useRouter()
 const { replace, push } = useRouter()
 async function loginOut(): Promise<void> {
 async function loginOut(): Promise<void> {

+ 8 - 0
src/main.ts

@@ -6,8 +6,12 @@ import router, { setupRouter } from './router' // 路由
 
 
 import { setupStore } from './store' // 状态管理
 import { setupStore } from './store' // 状态管理
 
 
+import { setupDirectives } from '@/directives' // 自定义指令
+
 import { setupGlobCom } from './components'
 import { setupGlobCom } from './components'
 
 
+import { setupElement } from '@/plugins/element-plus'
+
 import '@/styles/index.less'
 import '@/styles/index.less'
 
 
 import 'virtual:svg-icons-register'
 import 'virtual:svg-icons-register'
@@ -23,6 +27,10 @@ setupStore(app) // 引入状态管理
 
 
 setupRouter(app) // 引入路由
 setupRouter(app) // 引入路由
 
 
+setupDirectives(app)
+
+setupElement(app)
+
 setupGlobCom(app) // 引入全局组件
 setupGlobCom(app) // 引入全局组件
 
 
 router.isReady().then(() => {
 router.isReady().then(() => {

+ 18 - 0
src/plugins/element-plus/element.config.ts

@@ -0,0 +1,18 @@
+/**
+ * 为了保持多页element组件的样式统一,提供全局配置的方法。
+ */
+import { ConfigElement } from './types'
+
+const elementConfig: ConfigElement = {
+  /**
+   * 尺寸
+   */
+  size: 'medium',
+
+  /**
+   * 层级
+   */
+  zIndex: 2000
+}
+
+export default elementConfig

+ 23 - 0
src/plugins/element-plus/index.ts

@@ -0,0 +1,23 @@
+// 按需加载element
+// 目前需要手动引入loading等插件,无法自动导入
+// size和zIndex也需要这样设置,暂时还无法在全局配置组件中去设置
+// 需要看看后面官方是不是能优化这点
+import type { App } from 'vue'
+
+import ElementConfig from './element.config'
+
+// element全局配置项
+const { size, zIndex } = ElementConfig
+
+import { ElLoading } from 'element-plus'
+
+const plugins = [ElLoading]
+
+export function setupElement(app: App<Element>): void {
+  plugins.forEach((plugin: any) => {
+    app.use(plugin)
+  })
+
+  // 全局配置
+  app.config.globalProperties.$ELEMENT = { size: size, zIndex: zIndex }
+}

+ 7 - 0
src/plugins/element-plus/types.ts

@@ -0,0 +1,7 @@
+/**
+ * element配置
+ */
+export interface ConfigElement {
+  zIndex: number
+  size: 'medium' | 'small' | 'mini'
+}

+ 49 - 73
src/router/index.ts

@@ -144,71 +144,47 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
         meta: {
         meta: {
           title: '图片预览'
           title: '图片预览'
         }
         }
-      }
-      // {
-      //   path: 'button',
-      //   component: () => import('_v/components-demo/button/index.vue'),
-      //   name: 'ButtonDemo',
-      //   meta: {
-      //     title: '按钮'
-      //   }
-      // },
-      // {
-      //   path: 'message',
-      //   component: () => import('_v/components-demo/message/index.vue'),
-      //   name: 'MessageDemo',
-      //   meta: {
-      //     title: '消息提示'
-      //   }
-      // },
-      // {
-      //   path: 'count-to',
-      //   component: () => import('_v/components-demo/count-to/index.vue'),
-      //   name: 'CountToDemo',
-      //   meta: {
-      //     title: '数字动画'
-      //   }
-      // },
-      // {
-      //   path: 'search',
-      //   component: () => import('_v/components-demo/search/index.vue'),
-      //   name: 'SearchDemo',
-      //   meta: {
-      //     title: '查询'
-      //   }
-      // },
-      // {
-      //   path: 'editor',
-      //   component: () => import('_v/components-demo/editor/index.vue'),
-      //   name: 'EditorDemo',
-      //   meta: {
-      //     title: '富文本编辑器'
-      //   }
-      // },
-      // {
-      //   path: 'markdown',
-      //   component: () => import('_v/components-demo/markdown/index.vue'),
-      //   name: 'MarkdownDemo',
-      //   meta: {
-      //     title: 'markdown编辑器'
-      //   }
-      // },
-      // {
-      //   path: 'dialog',
-      //   component: () => import('_v/components-demo/dialog/index.vue'),
-      //   name: 'DialogDemo',
-      //   meta: {
-      //     title: '弹窗'
-      //   }
-      // },
-      // {
-      //   path: 'more',
-      //   component: () => import('_v/components-demo/more/index.vue'),
-      //   name: 'MoreDemo',
-      //   meta: {
-      //     title: '显示更多'
-      //   }
-      // },
+      },
+      {
+        path: 'message',
+        component: () => import('_v/components-demo/message/index.vue'),
+        name: 'MessageDemo',
+        meta: {
+          title: '消息提示'
+        }
+      },
+      {
+        path: 'count-to',
+        component: () => import('_v/components-demo/count-to/index.vue'),
+        name: 'CountToDemo',
+        meta: {
+          title: '数字动画'
+        }
+      },
+      {
+        path: 'search',
+        component: () => import('_v/components-demo/search/index.vue'),
+        name: 'SearchDemo',
+        meta: {
+          title: '查询'
+        }
+      },
+      {
+        path: 'editor',
+        component: () => import('_v/components-demo/editor/index.vue'),
+        name: 'EditorDemo',
+        meta: {
+          title: '富文本编辑器'
+        }
+      },
+      {
+        path: 'dialog',
+        component: () => import('_v/components-demo/dialog/index.vue'),
+        name: 'DialogDemo',
+        meta: {
+          title: '弹窗'
+        }
+      },
       // {
       // {
       //   path: 'detail',
       //   path: 'detail',
       //   component: () => import('_v/components-demo/detail/index.vue'),
       //   component: () => import('_v/components-demo/detail/index.vue'),
@@ -217,14 +193,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
       //     title: '详情组件'
       //     title: '详情组件'
       //   }
       //   }
       // },
       // },
-      // {
-      //   path: 'qrcode',
-      //   component: () => import('_v/components-demo/qrcode/index.vue'),
-      //   name: 'QrcodeDemo',
-      //   meta: {
-      //     title: '二维码组件'
-      //   }
-      // },
+      {
+        path: 'qrcode',
+        component: () => import('_v/components-demo/qrcode/index.vue'),
+        name: 'QrcodeDemo',
+        meta: {
+          title: '二维码'
+        }
+      }
       // {
       // {
       //   path: 'avatars',
       //   path: 'avatars',
       //   component: () => import('_v/components-demo/avatars/index.vue'),
       //   component: () => import('_v/components-demo/avatars/index.vue'),

+ 111 - 0
src/views/components-demo/count-to/index.vue

@@ -0,0 +1,111 @@
+<template>
+  <div>
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="基于 vue-count-to 进行改造,支持所有 vue-count-to 参数。"
+      type="info"
+      style="margin-bottom: 20px"
+    />
+
+    <div class="count-to">
+      <count-to
+        ref="countRef"
+        :start-val="startVal"
+        :end-val="endVal"
+        :duration="duration"
+        :decimals="decimals"
+        :separator="separator"
+        :prefix="prefix"
+        :suffix="suffix"
+        :autoplay="autoplay"
+        class="count-to__item"
+      />
+    </div>
+    <div class="action">
+      <el-row :gutter="20">
+        <el-col :span="8">
+          <div class="action__item">
+            <span>startVal:</span><el-input-number v-model="startVal" :min="0" />
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="action__item">
+            <span>endVal:</span><el-input-number v-model="endVal" :min="1" />
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="action__item">
+            <span>duration:</span><el-input-number v-model="duration" :min="1000" />
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="action__item"> <span>separator:</span><el-input v-model="separator" /> </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="action__item"> <span>prefix:</span><el-input v-model="prefix" /> </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="action__item"> <span>suffix:</span><el-input v-model="suffix" /> </div>
+        </el-col>
+        <el-col :span="24">
+          <div style="text-align: center; margin-top: 20px">
+            <el-button type="primary" @click="start">start</el-button>
+            <el-button style="margin-left: 10px" @click="pauseResume">pause/resume</el-button>
+          </div>
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="CountToDemo">
+import { ref, unref } from 'vue'
+import CountTo from '_c/CountTo/index.vue'
+const countRef = ref<HTMLElement | null>(null)
+const startVal = ref<number>(0)
+const endVal = ref<number>(1314512)
+const duration = ref<number>(3000)
+const decimals = ref<number>(0)
+const separator = ref<string>(',')
+const prefix = ref<string>('¥ ')
+const suffix = ref<string>(' rmb')
+const autoplay = ref<boolean>(false)
+
+function start(): void {
+  ;(unref(countRef) as any).start()
+}
+
+function pauseResume(): void {
+  ;(unref(countRef) as any).pauseResume()
+}
+</script>
+
+<style lang="less" scoped>
+.count-to {
+  text-align: center;
+  margin-top: 40px;
+  &__item {
+    font-size: 80px;
+    color: #f6416c;
+    font-weight: bold;
+  }
+}
+.action {
+  margin-top: 20px;
+  &__item {
+    padding: 0 15px;
+    display: flex;
+    align-items: center;
+    margin-bottom: 10px;
+    & > span {
+      display: inline-block;
+      width: 120px;
+      text-align: center;
+    }
+    :deep(.el-input-number) {
+      width: 100%;
+    }
+  }
+}
+</style>

+ 27 - 0
src/views/components-demo/dialog/index.vue

@@ -0,0 +1,27 @@
+<template>
+  <div>
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="对 Element 的 Dialog 组件进行二次封装,支持所有原生参数。"
+      type="info"
+      style="margin-bottom: 20px"
+    />
+    <el-button type="primary" @click="visible = true">打开弹窗</el-button>
+
+    <com-dialog v-model="visible" title="提示">
+      <div style="height: 1000px"> 我是弹窗内容 </div>
+      <template #footer>
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="visible = false">确定</el-button>
+      </template>
+    </com-dialog>
+  </div>
+</template>
+
+<script setup lang="ts" name="DialogDemo">
+import { ref } from 'vue'
+const visible = ref<boolean>(false)
+</script>
+
+<style></style>

+ 45 - 0
src/views/components-demo/editor/index.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="基于 wangeditor 封装的 富文本 组件。"
+      type="info"
+      style="margin-bottom: 20px"
+    />
+
+    <editor ref="editorRef" :value="content" @change="handleChange" />
+
+    <div style="margin-top: 20px; text-align: center">
+      <el-button @click="showHtml"> 获取TTML(请在控制台查看) </el-button>
+      <el-button @click="showText"> 获取TEXT(请在控制台查看) </el-button>
+      <el-button @click="showJson"> 获取JSON(请在控制台查看) </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="EditorDemo">
+import { ref, unref } from 'vue'
+import Editor from '_c/Editor/index.vue'
+
+const content = ref<string>('默认展示数据')
+const editorRef = ref<Nullable<any>>(null)
+
+function handleChange(html: string) {
+  console.log(html)
+}
+
+function showHtml() {
+  console.log((unref(editorRef) as any).getHtml())
+}
+
+function showText() {
+  console.log((unref(editorRef) as any).getText())
+}
+
+function showJson() {
+  console.log((unref(editorRef) as any).getJSON())
+}
+</script>
+
+<style></style>

+ 24 - 0
src/views/components-demo/message/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <div>
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="二次封装 Element 的 Message 组件,每次只显示最新一条消息,避免出现太多消息提示导致不美观。"
+      type="info"
+      style="margin-bottom: 20px"
+    />
+    <el-button @click="show">显示</el-button>
+  </div>
+</template>
+
+<script setup lang="ts" name="MessageDemo">
+import { ref } from 'vue'
+import { Message } from '_c/Message'
+const count = ref<number>(0)
+function show() {
+  count.value = count.value + 1
+  Message.success('这是成功消息' + count.value)
+}
+</script>
+
+<style></style>

+ 89 - 0
src/views/components-demo/qrcode/index.vue

@@ -0,0 +1,89 @@
+<template>
+  <div>
+    <el-row :gutter="20">
+      <el-col :span="6">
+        <div class="title-item">基础用法,默认canvas</div>
+        <qrcode text="vue-element-plus-admin" />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">img标签</div>
+        <qrcode text="vue-element-plus-admin" tag="img" />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">样式配置</div>
+        <qrcode
+          text="vue-element-plus-admin"
+          :options="{
+            color: {
+              dark: '#55D187',
+              light: '#2d8cf0'
+            }
+          }"
+        />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">点击</div>
+        <qrcode text="vue-element-plus-admin" @click="codeClick" />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">异步内容</div>
+        <qrcode :text="text" />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">二维码失效</div>
+        <qrcode text="vue-element-plus-admin" :disabled="true" @disabled-click="disabledClick" />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">logo配置</div>
+        <qrcode text="vue-element-plus-admin" :logo="logoImg" />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">logo样式配置</div>
+        <qrcode
+          text="vue-element-plus-admin"
+          :logo="{
+            src: logoImg,
+            logoSize: 0.2,
+            borderSize: 0.05,
+            borderRadius: 50,
+            bgColor: 'blue'
+          }"
+        />
+      </el-col>
+      <el-col :span="6">
+        <div class="title-item">大小配置</div>
+        <qrcode text="vue-element-plus-admin" :width="300" />
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts" name="QrcodeDemo">
+import { ref } from 'vue'
+import Qrcode from '_c/Qrcode/index.vue'
+import { Message } from '_c/Message'
+import logoImg from '@/assets/img/logo.png'
+
+const text = ref<string>('')
+setTimeout(() => {
+  text.value = '我是异步生成的内容'
+}, 3000)
+
+function codeClick() {
+  Message.info('我被点击了。')
+}
+function disabledClick() {
+  Message.info('我失效被点击了。')
+}
+</script>
+
+<style lang="less" scoped>
+.el-col {
+  text-align: center;
+  margin-bottom: 20px;
+  .title-item {
+    font-weight: bold;
+    margin-bottom: 10px;
+  }
+}
+</style>

+ 149 - 0
src/views/components-demo/search/data.ts

@@ -0,0 +1,149 @@
+export const classicData = [
+  {
+    label: '即时配送',
+    value: true,
+    itemType: 'switch',
+    field: 'delivery'
+  },
+  {
+    label: '活动名称',
+    value: '',
+    itemType: 'input',
+    field: 'name',
+    placeholder: '活动名称',
+    clearable: true,
+    rules: [
+      {
+        required: true,
+        message: '请输入活动名称'
+      }
+    ]
+  },
+  {
+    label: '活动区域',
+    value: '',
+    itemType: 'select',
+    placeholder: '活动区域',
+    clearable: true,
+    field: 'region',
+    options: [
+      {
+        title: '区域一',
+        value: 'fujian'
+      },
+      {
+        title: '区域二',
+        value: 'beijing'
+      }
+    ],
+    rules: [
+      {
+        itemType: 'string',
+        required: true,
+        message: '请选择活动区域'
+      }
+    ]
+  },
+  {
+    label: '特殊资源',
+    value: '2',
+    itemType: 'radio',
+    field: 'resource',
+    radioType: 'button', // button or radio
+    options: [
+      {
+        label: '线上品牌商赞助',
+        value: '1'
+      },
+      {
+        label: '线下场地免费',
+        value: '2'
+      }
+    ]
+  },
+  // {
+  //   label: '组织机构',
+  //   value: [],
+  //   itemType: 'treeSelect',
+  //   field: 'company',
+  //   allowClear: true,
+  //   placeholder: '请选择组织机构',
+  //   treeCheckable: false,
+  //   maxTagCount: 2,
+  //   options: [
+  //     {
+  //       title: 'Node1',
+  //       value: '0-0',
+  //       key: '0-0',
+  //       children: [
+  //         {
+  //           title: 'Child Node1',
+  //           value: '0-0-0',
+  //           key: '0-0-0'
+  //         }
+  //       ]
+  //     },
+  //     {
+  //       title: 'Node2',
+  //       value: '0-1',
+  //       key: '0-1',
+  //       children: [
+  //         {
+  //           title: 'Child Node3',
+  //           value: '0-1-0',
+  //           key: '0-1-0',
+  //           disabled: true
+  //         },
+  //         {
+  //           title: 'Child Node4',
+  //           value: '0-1-1',
+  //           key: '0-1-1'
+  //         },
+  //         {
+  //           title: 'Child Node5',
+  //           value: '0-1-2',
+  //           key: '0-1-2'
+  //         }
+  //       ]
+  //     }
+  //   ]
+  // },
+  {
+    label: '日选择器',
+    value: '',
+    itemType: 'datePicker',
+    field: 'date1',
+    clearable: true,
+    format: 'YYYY-MM-DD',
+    placeholder: '请选择日期'
+  },
+  {
+    label: '月选择器',
+    value: '',
+    itemType: 'datePicker',
+    field: 'date2',
+    clearable: true,
+    format: 'YYYY-MM',
+    placeholder: '请选择日期'
+  },
+  {
+    label: '范围选择器',
+    value: [],
+    itemType: 'datePicker',
+    field: 'date3',
+    clearable: true,
+    type: 'daterange',
+    rangeSeparator: '至',
+    startPlaceholder: '开始日期',
+    endPlaceholder: '结束日期'
+  },
+  {
+    label: '周选择器',
+    value: '',
+    itemType: 'datePicker',
+    field: 'date4',
+    type: 'week',
+    clearable: true,
+    placeholder: '请选择日期'
+  }
+]

+ 95 - 0
src/views/components-demo/search/index.vue

@@ -0,0 +1,95 @@
+<template>
+  <div>
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="封装 Element 的 Form 组件,实现查询、重置等功能,并提供了三种布局风格。"
+      type="info"
+      style="margin-bottom: 20px"
+    />
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="经典风格。"
+      type="info"
+      style="margin-bottom: 20px; margin-top: 20px"
+    />
+    <div class="searh">
+      <com-search :data="classicData" @search-submit="searchSubmit1" @reset-submit="resetSubmit1" />
+      <div> 查询/重置后的数据:{{ formData1 }} </div>
+    </div>
+
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="底部操作按钮风格。"
+      type="info"
+      style="margin-bottom: 20px; margin-top: 20px"
+    />
+    <div class="searh">
+      <com-search
+        layout="bottom"
+        :data="classicData"
+        @search-submit="searchSubmit2"
+        @reset-submit="resetSubmit2"
+      />
+      <div> 查询/重置后的数据:{{ formData2 }} </div>
+    </div>
+
+    <el-alert
+      effect="dark"
+      :closable="false"
+      title="右侧操作按钮风格。"
+      type="info"
+      style="margin-bottom: 20px; margin-top: 20px"
+    />
+    <div class="searh">
+      <com-search
+        layout="right"
+        :data="classicData"
+        @search-submit="searchSubmit3"
+        @reset-submit="resetSubmit3"
+      />
+      <div> 查询/重置后的数据:{{ formData3 }} </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="SearchDemo">
+import { ref } from 'vue'
+import { classicData } from './data'
+const formData1 = ref<Nullable<IObj>>(null)
+const formData2 = ref<Nullable<IObj>>(null)
+const formData3 = ref<Nullable<IObj>>(null)
+
+function searchSubmit1(data: any): void {
+  formData1.value = data
+}
+
+function resetSubmit1(data: any): void {
+  formData1.value = data
+}
+
+function searchSubmit2(data: any): void {
+  formData2.value = data
+}
+
+function resetSubmit2(data: any): void {
+  formData2.value = data
+}
+
+function searchSubmit3(data: any): void {
+  formData3.value = data
+}
+
+function resetSubmit3(data: any): void {
+  formData3.value = data
+}
+</script>
+
+<style lang="less" scoped>
+.searh {
+  background: #fff;
+  padding: 20px;
+}
+</style>