This commit is contained in:
wangzenghua 2025-04-18 09:06:30 +01:00
commit 3d75632663
84 changed files with 12730 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*.{js,jsx,ts,tsx,vue}]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = auto

6
.env.development Normal file
View File

@ -0,0 +1,6 @@
# 开发环境
VITE_PORT = 9000
VITE_APP_NAME = 'digital-agriculture-screen'
VITE_APP_NAME = '政务云数据大屏可视化'
VITE_APP_BASE_API = '/apis'
VITE_APP_BASE_URL = 'http://192.168.18.99:8080'

5
.env.production Normal file
View File

@ -0,0 +1,5 @@
# 生产环境
VITE_APP_NAME = 'digital-agriculture-screen'
VITE_APP_NAME = '政务云数据大屏可视化'
VITE_APP_BASE_API = '/apis'
VITE_APP_BASE_URL = ''

5
.env.test Normal file
View File

@ -0,0 +1,5 @@
# 测试环境
VITE_APP_NAME = 'digital-agriculture-screen'
VITE_APP_NAME = '政务云数据大屏可视化'
VITE_APP_BASE_API = '/apis'
VITE_APP_BASE_URL = ''

14
.eslintignore Normal file
View File

@ -0,0 +1,14 @@
*.sh
*.md
*.woff
*.ttf
.vscode
.idea
.husky
.local
dist
src/assets
node_modules
Dockerfile
stats.html
tailwind.config.js

62
.eslintrc.cjs Normal file
View File

@ -0,0 +1,62 @@
/*
* @Descripttion: .eslintrc.cjs
* 在VSCode中安装ESLint插件编写过程中检测代码质量
* ESLint 代码质量校验相关配置
* 这里使用prettier作为代码格式化工具用ESLint做代码质检
* 相关配置使用下面extends扩展先做默认设置
* .prettierrc.cjs文件中配置好后格式化规则会以.prettierrc.cjs作为最终格式所以不建议在本文件中做代码格式化相关配置
* 相关prettier配置ESLint会默认加载为代码质检 格式化以prettier为主
* 在本配置文件中只做代码质量约束规范配置
* @Author: zenghua.wang
* @Date: 2022-09-22 15:53:58
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-03-22 10:19:39
*/
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint-config-prettier',
'eslint:recommended',
// 'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended',
'plugin:vue/vue3-essential',
'plugin:prettier/recommended',
],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
requireConfigFile: false,
parser: '@babel/eslint-parser',
// parser: '@typescript-eslint/parser',
},
plugins: ['vue', 'prettier'],
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly',
},
// 这里时配置规则的,自己看情况配置
rules: {
'prettier/prettier': 'error',
'no-debugger': 'off',
'no-unused-vars': 'off',
'vue/no-unused-vars': 'off',
'vue/multi-word-component-names': 'off',
},
};

115
.gitignore vendored Normal file
View File

@ -0,0 +1,115 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
# .env
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

20
.prettierignore Normal file
View File

@ -0,0 +1,20 @@
## OS
.DS_Store
node_modules
.idea
.editorconfig
package-lock.json
.npmrc
# Ignored suffix
*.log
*.md
*.svg
*.png
*ignore
## Local
## Built-files
.cache
dist

52
.prettierrc.cjs Normal file
View File

@ -0,0 +1,52 @@
/*
* @Descripttion: .prettierrc.cjs
* 在VSCode中安装prettier插件 打开插件配置填写`.prettierrc.js` 将本文件作为其代码格式化规范
* 在本文件中修改格式化规则不会同时触发改变ESLint代码检查所以每次修改本文件需要重启VSCodeESLint检查才能同步代码格式化
* 需要相应的代码格式化规范请自行查阅配置下面为默认项目配置
* @Author: zenghua.wang
* @Date: 2022-09-22 15:53:58
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-01-24 19:22:25
*/
module.exports = {
// 一行最多多少个字符
printWidth: 150,
// 指定每个缩进级别的空格数
tabWidth: 2,
// 使用制表符而不是空格缩进行
useTabs: false,
// 在语句末尾是否需要分号
semi: true,
// 是否使用单引号
singleQuote: true,
// 更改引用对象属性的时间 可选值"<as-needed|consistent|preserve>"
quoteProps: 'as-needed',
// 在JSX中使用单引号而不是双引号
jsxSingleQuote: false,
// 多行时尽可能打印尾随逗号。(例如,单行数组永远不会出现逗号结尾。) 可选值"<none|es5|all>"默认none
trailingComma: 'es5',
// 在对象文字中的括号之间打印空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
jsxBracketSameLine: false,
// 在单独的箭头函数参数周围包括括号 always(x) => x \ avoidx => x
arrowParens: 'always',
// 这两个选项可用于格式化以给定字符偏移量(分别包括和不包括)开始和结束的代码
rangeStart: 0,
rangeEnd: Infinity,
// 指定要使用的解析器,不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准 always\never\preserve
proseWrap: 'preserve',
// 指定HTML文件的全局空格敏感度 css\strict\ignore
htmlWhitespaceSensitivity: 'css',
// Vue文件脚本和样式标签缩进
vueIndentScriptAndStyle: false,
//在 windows 操作系统中换行符通常是回车 (CR) 加换行分隔符 (LF),也就是回车换行(CRLF)
//然而在 Linux 和 Unix 中只使用简单的换行分隔符 (LF)。
//对应的控制字符为 "\n" (LF) 和 "\r\n"(CRLF)。auto意为保持现有的行尾
// 换行符使用 lf 结尾是 可选值"<auto|lf|crlf|cr>"
endOfLine: 'auto',
};

17
.stylelintignore Normal file
View File

@ -0,0 +1,17 @@
# .stylelintignore
# 旧的不需打包的样式库
*.min.css
# 其他类型文件
*.js
*.jpg
*.png
*.eot
*.ttf
*.woff
*.json
# 测试和打包目录
/dist/*
/node_modules/*
/src/assets/*

131
.stylelintrc.cjs Normal file
View File

@ -0,0 +1,131 @@
/*
* @Descripttion: .stylelintrc.cjs
* @Author: zenghua.wang
* @Date: 2022-09-22 15:53:58
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-01-24 18:49:26
*/
module.exports = {
root: true,
plugins: ['stylelint-order', 'stylelint-scss'],
extends: [
'stylelint-config-standard',
'stylelint-config-standard-scss',
'stylelint-config-prettier',
'stylelint-config-html/vue',
'stylelint-config-recommended-vue',
'stylelint-config-recommended-scss'
],
overrides: [
{
files: ['**/*.{html,vue}'],
customSyntax: 'postcss-html'
}
],
rules: {
indentation: 2,
'selector-pseudo-element-no-unknown': [
true,
{
ignorePseudoElements: ['v-deep', ':deep']
}
],
'number-leading-zero': 'always',
'no-descending-specificity': null,
'function-url-quotes': 'always',
'string-quotes': 'single',
'unit-case': null,
'color-hex-case': 'lower',
'color-hex-length': 'long',
'rule-empty-line-before': 'never',
'font-family-no-missing-generic-family-keyword': null,
'selector-type-no-unknown': null,
'block-opening-brace-space-before': 'always',
'at-rule-no-unknown': null,
'no-duplicate-selectors': null,
'property-no-unknown': null,
'no-empty-source': null,
'selector-class-pattern': null,
'keyframes-name-pattern': null,
'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global', 'deep'] }],
'function-no-unknown': null,
'order/properties-order': [
'position',
'top',
'right',
'bottom',
'left',
'z-index',
'display',
'justify-content',
'align-items',
'float',
'clear',
'overflow',
'overflow-x',
'overflow-y',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'width',
'min-width',
'max-width',
'height',
'min-height',
'max-height',
'font-size',
'font-family',
'font-weight',
'border',
'border-style',
'border-width',
'border-color',
'border-top',
'border-top-style',
'border-top-width',
'border-top-color',
'border-right',
'border-right-style',
'border-right-width',
'border-right-color',
'border-bottom',
'border-bottom-style',
'border-bottom-width',
'border-bottom-color',
'border-left',
'border-left-style',
'border-left-width',
'border-left-color',
'border-radius',
'text-align',
'text-justify',
'text-indent',
'text-overflow',
'text-decoration',
'white-space',
'color',
'background',
'background-position',
'background-repeat',
'background-size',
'background-color',
'background-clip',
'opacity',
'filter',
'list-style',
'outline',
'visibility',
'box-shadow',
'text-shadow',
'resize',
'transition'
]
}
};

75
auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

38
components.d.ts vendored Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
Components: typeof import('./src/components/index.js')['default']
CustomCarouselPicture: typeof import('./src/components/custom-carousel-picture/index.vue')['default']
CustomEchartBar: typeof import('./src/components/custom-echart-bar/index.vue')['default']
CustomEchartBubble: typeof import('./src/components/custom-echart-bubble/index.vue')['default']
CustomEchartLine: typeof import('./src/components/custom-echart-line/index.vue')['default']
CustomEchartLineLine: typeof import('./src/components/custom-echart-line-line/index.vue')['default']
CustomEchartMaps: typeof import('./src/components/custom-echart-maps/index.vue')['default']
CustomEchartMixin: typeof import('./src/components/custom-echart-mixin/index.vue')['default']
CustomEchartPictorialBar: typeof import('./src/components/custom-echart-pictorial-bar/index.vue')['default']
CustomEchartPie: typeof import('./src/components/custom-echart-pie/index.vue')['default']
CustomEchartPie3d: typeof import('./src/components/custom-echart-pie-3d/index.vue')['default']
CustomEchartPieGauge: typeof import('./src/components/custom-echart-pie-gauge/index.vue')['default']
CustomEchartRadar: typeof import('./src/components/custom-echart-radar/index.vue')['default']
CustomEchartScatterBlister: typeof import('./src/components/custom-echart-scatter-blister/index.vue')['default']
CustomEchartWaterDroplet: typeof import('./src/components/custom-echart-water-droplet/index.vue')['default']
CustomEchartWordCloud: typeof import('./src/components/custom-echart-word-cloud/index.vue')['default']
CustomIframe: typeof import('./src/components/custom-iframe/index.vue')['default']
CustomImportExcel: typeof import('./src/components/custom-import-excel/index.vue')['default']
CustomRankList: typeof import('./src/components/custom-rank-list/index.vue')['default']
CustomRichEditor: typeof import('./src/components/custom-rich-editor/index.vue')['default']
CustomScrollBoard: typeof import('./src/components/custom-scroll-board/index.vue')['default']
CustomTableOperate: typeof import('./src/components/custom-table-operate/index.vue')['default']
CustomTableTree: typeof import('./src/components/custom-table-tree/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./src/components/svg-icon/index.vue')['default']
UpFile: typeof import('./src/components/custom-rich-editor/upFile.js')['default']
}
}

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据大屏</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

79
package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "digital-agriculture-screen",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --mode development",
"build": "vite build --mode production",
"test": "vite build --mode test",
"preview": "vite preview",
"format": "prettier --write 'src/**/*.{vue,ts,tsx,js,jsx,css,less,scss,json,md}'",
"eslint": "npx eslint --init",
"lint": "npm run lint:script && npm run lint:style",
"lint:style": "stylelint 'src/**/*.{vue,scss,css,sass,less}' --fix",
"lint:script": "eslint --ext .js,.ts,.tsx,.vue --fix --quiet ./src"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@smallwei/avue": "^3.6.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"animate.css": "^4.1.1",
"axios": "^1.6.5",
"echarts": "^5.6.0",
"echarts-gl": "^2.0.9",
"element-plus": "^2.7.2",
"js-base64": "^3.7.6",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"screenfull": "^6.0.2",
"vue": "^3.3.11",
"vue-cesium": "^3.2.9",
"vue-router": "^4.2.5",
"vue3-scroll-seamless": "^1.0.6"
},
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/eslint-parser": "^7.23.3",
"@types/path-browserify": "^1.0.2",
"@vitejs/plugin-vue": "^4.5.2",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.20.1",
"mockjs": "^1.1.0",
"postcss": "^8.4.33",
"postcss-html": "^1.6.0",
"postcss-import": "^16.0.0",
"prettier": "^3.2.4",
"sass": "^1.70.0",
"stylelint": "^16.2.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-recommended": "^14.0.0",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-config-standard-scss": "^13.0.0",
"stylelint-order": "^6.0.4",
"stylelint-scss": "^6.1.0",
"terser": "^5.27.0",
"unplugin-auto-import": "^0.17.3",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.8",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-mock": "^3.0.1",
"vite-plugin-progress": "^0.0.7",
"vite-plugin-qiankun": "^1.0.15",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-setup-extend": "^0.4.0"
}
}

BIN
public/images/avatar.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

21
src/App.vue Normal file
View File

@ -0,0 +1,21 @@
<template>
<el-config-provider :size="size" :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup name="app">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useSettingStore } from '@/store/modules/setting';
// element
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import { createApp } from 'vue';
import App from './App.vue';
const SettingStore = useSettingStore();
//
const size = computed(() => SettingStore.themeConfig.globalComSize);
</script>
<style lang="scss">
@import './styles/style';
</style>

35
src/apis/index.js Normal file
View File

@ -0,0 +1,35 @@
// import request from '@/utils/axios';
// import { isEmpty } from '@/utils';
// /**
// * @Title: 获取字典
// */
// export function CommonDicData(params = { pageNum: 1, pageSize: 20, dictType: null }) {
// if (isEmpty(params?.dictType)) return;
// return request(`/system/dict/data/list`, {
// method: 'GET',
// apisType: 'dicData',
// params,
// });
// }
// /**
// * @Title: 上传图片
// */
// export function CommonUpload(data, params) {
// return request(`/upload`, {
// method: 'POST',
// apisType: 'upload',
// uploadType: 'multipart/form-data',
// data,
// params,
// });
// }
// //云南省所有区域信息
// export function getRegion(code) {
// let codeVal = code ? code : '530000';
// return request('/system/area/region?areaCode=' + codeVal, {
// method: 'GET',
// });
// }

View File

@ -0,0 +1,233 @@
<template>
<div class="carousel" style="width: 500px">
<el-carousel
v-if="type === 'image'"
ref="carouselRef"
:interval="option.interval"
:arrow="option.arrow"
:indicator-position="option.indicatorPosition"
@change="handleChange"
>
<el-carousel-item v-for="(item, index) in data" :key="index">
<img :src="item.image" style="width: 100%; height: auto" />
</el-carousel-item>
</el-carousel>
<div v-if="type === 'video'" class="carousel-video">
<!-- <img :src="state.videoPicture" class="carousel-video-picture" /> -->
<video ref="videoRef" controls class="carousel-video-video" width="100%" height="100%" @ended="pauseVideo">
<source :src="state.videoUrl" type="video/mp4" />
Your browser does not support the video tag.
</video>
<!-- <span class="carousel-video-btn" @click="handlePlay">
<el-icon><VideoPlay /></el-icon>
</span> -->
</div>
<div class="carousel-container">
<span class="carousel-arrow carousel-arrow-left" @click="handleLeftClick">
<el-icon><ArrowLeftBold /></el-icon>
</span>
<el-scrollbar ref="scrollbarRef" class="carousel-list">
<div
v-for="(item, index) in data"
:key="index"
:class="`carousel-list-item ${state.current === index ? 'active' : ''}`"
@click="handleClick(index)"
>
<el-image style="width: 100px; height: 100px" :src="item.image ?? item" fit="cover" />
</div>
</el-scrollbar>
<span class="carousel-arrow carousel-arrow-right" @click="handleRightClick">
<el-icon><ArrowRightBold /></el-icon>
</span>
</div>
</div>
</template>
<script setup name="custom-carousel-picture">
import { reactive, ref } from 'vue';
const props = defineProps({
data: { type: Array, default: () => [] },
type: { type: String, default: 'image' },
option: {
type: Object,
default: () => {
return {
height: '',
initialIndex: 0,
autoplay: true,
loop: true,
interval: 3000,
arrow: 'never',
indicatorPosition: 'none',
};
},
},
});
const emit = defineEmits(['']);
const carouselRef = ref(null);
const scrollbarRef = ref(null);
const videoRef = ref(null);
const state = reactive({
current: 0,
isReady: false,
videoPicture: '',
videoUrl: '',
});
const handleChange = (cur, last) => {
state.current = cur;
};
const handleLeftClick = () => {
// const index = carouselRef.value.activeIndex;
// carouselRef.value.setActiveItem(index + 1);
scrollbarRef.value.setScrollLeft(scrollbarRef.value.wrapRef.scrollLeft - 120);
};
const handleRightClick = () => {
// const index = carouselRef.value.activeIndex;
// carouselRef.value.setActiveItem(index - 1);
scrollbarRef.value.setScrollLeft(scrollbarRef.value.wrapRef.scrollLeft + 120);
};
const playVideo = () => {
if (videoRef.value) {
videoRef.value.play();
}
};
const pauseVideo = () => {
if (videoRef.value) {
videoRef.value.pause();
}
};
const handleClick = (index) => {
const { type, data } = props;
switch (type) {
case 'image': {
carouselRef.value.setActiveItem(index);
break;
}
case 'video': {
const url = data[index];
state.videoUrl = url;
playVideo();
break;
}
}
};
const handlePlay = () => {
playVideo();
};
</script>
<style lang="scss" scoped>
.carousel {
width: 100%;
display: flex;
flex-direction: column;
&-container {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 30px;
}
&-list {
flex: 3;
:deep(.el-scrollbar__bar) {
display: none !important;
}
:deep(.el-scrollbar__view) {
display: flex;
flex-direction: row;
align-items: center;
}
&-item {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
margin: 10px;
border: 2px solid #fff;
text-align: center;
border-radius: 4px;
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
cursor: pointer;
&.active {
border-color: var(--el-color-primary);
}
}
}
&-arrow {
width: 50px;
text-align: center;
.el-icon {
display: inline-block;
width: 40px;
height: 40px;
margin: 0 auto;
line-height: 40px;
font-size: 32px;
cursor: pointer;
}
}
&-video {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
&-picture {
position: absolute;
z-index: 10;
}
&-video {
position: absolute;
z-index: 11;
}
&-btn {
position: absolute;
left: 50%;
top: 50%;
z-index: 12;
width: 50px;
height: 50px;
margin-left: -25px;
margin-top: -25px;
border-radius: 25px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.2);
cursor: pointer;
.el-icon {
font-size: 32px;
color: #fff;
}
}
}
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartBar',
props: {
chartData: {
type: Array,
default: () => [],
required: true,
},
option: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'bar',
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
isSeries: {
type: Boolean,
default: false,
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance } = useEcharts(chartRef);
const option = reactive({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
label: {
show: true,
backgroundColor: '#333',
},
},
},
legend: {
top: 30,
},
grid: {
top: 60,
},
xAxis: {
type: 'category',
data: [],
},
yAxis: {
type: 'value',
},
series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
let typeArr = Array.from(new Set(props.chartData.map((item) => item.type)));
let xAxisData = Array.from(new Set(props.chartData.map((item) => item.name)));
let seriesData = [];
typeArr.forEach((type, index) => {
const barStyle = props.option?.barStyle ?? {};
let obj = { name: type, type: props.type, ...barStyle };
let data = [];
xAxisData.forEach((x) => {
let dataArr = props.chartData.filter((item) => type === item.type && item.name == x);
if (dataArr && dataArr.length > 0) {
data.push(dataArr[0].value);
} else {
data.push(null);
}
});
obj['data'] = data;
if (props.option?.color) {
obj.color = props.option?.color[index];
}
seriesData.push(obj);
});
option.series = props.isSeries && option.series.length > 0 ? option.series : seriesData;
option.xAxis.data = xAxisData;
setOptions(option);
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,103 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'customEchartBubble',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
tooltip: {
formatter: function (params) {
console.log(params);
var str = params.marker + '' + params.data.name + '</br>' + '交易额:' + params.data.value + '万元</br>';
return str;
},
},
animationDurationUpdate: function (idx) {
//
return idx * 100;
},
animationEasingUpdate: 'bounceIn',
color: ['#fff', '#fff', '#fff'],
series: [
{
type: 'graph',
layout: 'force',
force: {
repulsion: 80,
edgeLength: 20,
},
roam: true,
label: {
normal: {
show: true,
},
},
data: props.chartData,
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
console.info(' option.series[0]', props.chartData);
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series[0].data = props.chartData;
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,70 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartLineLine',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const optionVal = reactive({});
watchEffect(() => {
props.option && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(optionVal, cloneDeep(props.option));
}
setOptions(props.option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,152 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartLine',
props: {
chartData: {
type: Array,
default: () => [],
required: true,
},
option: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'line',
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance } = useEcharts(chartRef);
const option = reactive({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
label: {
show: true,
backgroundColor: '#333',
},
},
},
legend: {
top: 30,
},
grid: {
top: 60,
},
xAxis: {
type: 'category',
data: [],
},
yAxis: {
type: 'value',
},
series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
function hexToRGBA(hex, alpha = 1) {
let hexCode = hex.replace('#', '');
if (hexCode.length === 3) {
hexCode = hexCode
.split('')
.map((char) => char + char)
.join('');
}
const r = parseInt(hexCode.slice(0, 2), 16);
const g = parseInt(hexCode.slice(2, 4), 16);
const b = parseInt(hexCode.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function setAreaStyle(color) {
return {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: hexToRGBA(color, 0.2),
},
{
offset: 1,
color: hexToRGBA(color, 1),
},
],
},
};
}
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
let typeArr = Array.from(new Set(props.chartData.map((item) => item.type)));
let xAxisData = Array.from(new Set(props.chartData.map((item) => item.name)));
let seriesData = [];
typeArr.forEach((type, index) => {
let obj = {
name: type,
type: props.type,
smooth: true,
};
if (props.option?.color) {
obj.areaStyle = setAreaStyle(props.option?.color[index]);
}
const findItem = props.chartData.find((item) => item.type == type);
if (findItem && findItem.color) {
obj.color = findItem.color;
obj.areaStyle = setAreaStyle(findItem.color[index]);
}
let data = [];
xAxisData.forEach((x) => {
let dataArr = props.chartData.filter((item) => type === item.type && item.name == x);
if (dataArr && dataArr.length > 0) {
data.push(dataArr[0].value);
} else {
data.push(null);
}
});
obj['data'] = data;
seriesData.push(obj);
});
option.series = seriesData;
option.xAxis.data = xAxisData;
setOptions(option);
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,84 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartMaps',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
geo: {
type: Object,
default: () => {
return {};
},
},
name: {
type: String,
default: '',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize, registerMap } = useEcharts(chartRef);
const option = reactive({
// series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
// option.series = props.chartData;
setOptions(option);
registerMap(props.name, props.geo);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,91 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartMixin',
props: {
chartData: {
type: Array,
default: () => [],
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
setup(props) {
const chartRef = ref(null);
const { setOptions } = useEcharts(chartRef);
const option = reactive({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
label: {
show: true,
backgroundColor: '#333',
},
},
},
xAxis: {
type: 'category',
data: [],
},
yAxis: {
type: 'value',
},
series: [
{
name: 'bar',
type: 'bar',
data: [],
itemStyle: {
barWidth: 10,
},
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
let typeArr = Array.from(new Set(props.chartData.map((item) => item.type)));
let xAxisData = Array.from(new Set(props.chartData.map((item) => item.name)));
let seriesData = [];
typeArr.forEach((type, index) => {
const barStyle = props.option?.barStyle ?? {};
let obj = { name: type, ...barStyle };
let chartArr = props.chartData.filter((item) => type === item.type);
obj['data'] = chartArr.map((item) => item.value);
obj['type'] = chartArr[0].seriesType;
obj['stack'] = chartArr[0].stack;
obj['itemStyle'] = chartArr[0].itemStyle;
seriesData.push(obj);
});
option.series = seriesData;
option.xAxis.data = xAxisData;
setOptions(option);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,119 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'customEchartPictorialBar',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
grid: {
left: '3%',
right: '4%',
bottom: '6%',
top: '11%',
containLabel: true,
},
tooltip: {
formatter: '{b}',
},
series: [
{
type: 'pictorialBar',
barCategoryGap: '40%',
barWidth: '100%',
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
data: [],
labelLine: { show: true },
z: 10,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: '#000001', //
},
{
offset: 1,
color: '#0175b6', //
},
],
global: false, // false
},
},
label: {
show: false,
position: 'top',
formatter: '{c}',
color: 'white',
fontSize: 14,
},
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,73 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartPie3d',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series = props.chartData;
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,73 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartPieGauge',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series = props.chartData;
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,89 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartPie',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
tooltip: {
formatter: '{b} ({c})',
},
series: [
{
type: 'pie',
radius: '72%',
center: ['50%', '55%'],
data: [],
labelLine: { show: true },
label: {
show: true,
formatter: '{b} \n ({d}%)',
color: '#B1B9D3',
},
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series[0].data = props.chartData;
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,92 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartRadar',
props: {
chartData: {
type: Array,
default: () => [],
required: true,
},
option: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'radar',
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance } = useEcharts(chartRef);
const option = reactive({
title: {
text: '',
},
legend: {},
radar: {},
tooltip: {},
series: [
{
type: 'radar',
data: [],
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
let typeArr = Array.from(new Set(props.chartData.map((item) => item.type)));
let indicator = Array.from(
new Set(
props.chartData.map((item) => {
let { name, max } = item;
return { name, max };
})
)
);
let data = [];
typeArr.forEach((type) => {
const radarStyle = props.option?.radarStyle ?? {};
let obj = { name: type, ...radarStyle };
let chartArr = props.chartData.filter((item) => type === item.type);
obj['value'] = chartArr.map((item) => item.value);
data.push(obj);
});
option.radar.indicator = indicator;
option.series[0]['data'] = data;
setOptions(option);
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,73 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'customEchartScatterBlister',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series = props.chartData;
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,88 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'customEchartWaterDroplet',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
tooltip: {
formatter: '{b} ({c})',
},
series: [
{
type: 'pie',
radius: '72%',
center: ['50%', '55%'],
data: [],
labelLine: { show: true },
label: {
show: true,
formatter: '{b} \n ({d}%)',
color: '#B1B9D3',
},
},
],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
setOptions(props.option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,73 @@
<template>
<div ref="chartRef" :style="{ height, width }"></div>
</template>
<script>
import { ref, reactive, watch, watchEffect } from 'vue';
import { cloneDeep } from 'lodash';
import { useEcharts } from '../../hooks/useEcharts';
export default {
name: 'CustomEchartWordCloud',
props: {
chartData: {
type: Array,
default: () => [],
},
size: {
type: Object,
default: () => {},
},
option: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: 'calc(100vh - 78px)',
},
},
emits: ['click'],
setup(props, { emit }) {
const chartRef = ref(null);
const { setOptions, getInstance, resize } = useEcharts(chartRef);
const option = reactive({
series: [],
});
watchEffect(() => {
props.chartData && initCharts();
});
watch(
() => props.size,
() => {
resize();
},
{
immediate: true,
}
);
function initCharts() {
if (props.option) {
Object.assign(option, cloneDeep(props.option));
}
option.series = props.chartData;
setOptions(option);
resize();
getInstance()?.off('click', onClick);
getInstance()?.on('click', onClick);
}
function onClick(params) {
emit('click', params);
}
return { chartRef };
},
};
</script>

View File

@ -0,0 +1,39 @@
<template>
<iframe v-if="state.url" :src="state.url" frameborder="0" width="100%" height="100%" @load="onLoad"></iframe>
</template>
<script setup name="custom-iframe">
import { reactive, watch } from 'vue';
import { isExternal } from '@/utils/validate';
const props = defineProps({
url: {
type: String,
required: true,
},
});
const emit = defineEmits(['on-load']);
const state = reactive({
url: '',
loaded: false,
});
watch(
() => props.url,
(val) => {
if (isExternal(val)) {
state.url = val;
}
},
{
immediate: true,
}
);
const onLoad = () => {
state.loaded = true;
emit('on-load', state.loaded);
};
</script>

View File

@ -0,0 +1,105 @@
<template>
<el-dialog
v-model="state.visible"
draggable
append-to-body
:title="title"
width="50%"
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="onClose"
>
<div class="import-tips">
<p>{{ tips }}</p>
<el-button v-if="templateUrl" type="primary" icon="download" text @click="emit('on-download', templateUrl)">下载模板</el-button>
</div>
<el-upload ref="uploadRef" drag action="#" :show-file-list="true" accept=".xlsx,.xls" :limit="1" :http-request="onUploadExcel">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件放在此处或单击上传</div>
<template #tip>
<div class="el-upload__tip">仅允许导入xlsxlsx格式文件excel文件大小小于500kb</div>
</template>
</el-upload>
<template #footer>
<el-button type="primary" @click="onConfirm"> 确定导入</el-button>
<el-button @click="onClose"> 取消</el-button>
</template>
</el-dialog>
</template>
<script setup name="custom-import-excel">
import { reactive, ref, shallowRef } from 'vue';
import { isEmpty } from '@/utils';
const props = defineProps({
title: {
type: String,
default: '文件导入',
},
tips: {
type: String,
default: '提示:导入前请先下载模板填写信息,然后再导入!',
},
templateUrl: {
type: [String, URL],
default: '',
},
// options: {
// type: Object,
// default: () => {
// return {
// tips: '',
// };
// },
// },
});
const emit = defineEmits(['on-confirm', 'on-close', 'on-download']);
const uploadRef = ref(null);
const formDate = shallowRef(null);
const state = reactive({
visible: false,
loading: false,
});
const onUploadExcel = ({ file }) => {
if (isEmpty(file.name)) return;
formDate.value = new FormData();
formDate.value.append('file', file);
};
const onConfirm = () => {
emit('on-confirm', formDate.value);
};
const onClose = () => {
uploadRef?.value && uploadRef.value.clearFiles();
state.visible = false;
};
defineExpose({
show: () => {
formDate.value = null;
state.visible = true;
},
hide: () => {
onClose();
},
clear: () => {
uploadRef?.value && uploadRef.value.clearFiles();
},
});
</script>
<style lang="scss" scoped>
.import {
&-tips {
@include flex-row();
align-items: center;
margin-bottom: 20px;
font-size: 14px;
color: #979797;
p {
flex: 3;
}
}
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div class="go-tables-rank" :style="`color: ${textColor}`">
<div v-for="(item, i) in status.rows" :key="item.toString() + item.scroll" class="row-item" :style="`height: ${status.heights[i]}px;`">
<div class="ranking-info">
<div class="rank" :style="`color: ${color};font-size: ${indexFontSize}px`">No.{{ item.ranking }}</div>
<div class="info-name" :style="`font-size: ${leftFontSize}px`" v-html="item.name" />
<div class="ranking-value" :style="`color: ${textColor};font-size: ${rightFontSize}px`">
{{ status.mergedConfig.valueFormatter ? status.mergedConfig.valueFormatter(item) : item.value }}
{{ unit }}
</div>
</div>
<div class="ranking-column" :style="`border-color: ${borderColor}`">
<div class="inside-column" :style="`width: ${item.percent}%;background-color: ${color}`">
<div class="shine" />
</div>
</div>
</div>
</div>
</template>
<script setup name="custom-rank-list">
import { onUnmounted, reactive, toRefs, watch } from 'vue';
const props = defineProps({
chartConfig: {
type: Object,
required: true,
},
});
const { w, h } = toRefs(props.chartConfig.attr);
const { rowNum, unit, color, textColor, borderColor, indexFontSize, leftFontSize, rightFontSize } = toRefs(props.chartConfig.option);
const status = reactive({
mergedConfig: props.chartConfig.option,
rowsData: [],
rows: [
{
scroll: 0,
ranking: 1,
name: '',
value: '',
percent: 0,
},
],
heights: [0],
animationIndex: 0,
animationHandler: 0,
updater: 0,
avgHeight: 0,
});
const calcRowsData = () => {
let { dataset, rowNum, sort } = status.mergedConfig;
// @ts-ignore
sort &&
dataset.sort(({ value: a }, { value: b }) => {
if (a > b) return -1;
if (a < b) return 1;
if (a === b) return 0;
});
// @ts-ignore
const value = dataset.map(({ value }) => value);
const min = Math.min(...value) || 0;
// abs of min
const minAbs = Math.abs(min);
const max = Math.max(...value) || 0;
// abs of max
const maxAbs = Math.abs(max);
const total = max + minAbs;
dataset = dataset.map((row, i) => ({
...row,
ranking: i + 1,
percent: ((row.value + minAbs) / total) * 100,
}));
const rowLength = dataset.length;
if (rowLength > rowNum && rowLength < 2 * rowNum) {
dataset = [...dataset, ...dataset];
}
dataset = dataset.map((d, i) => ({ ...d, scroll: i }));
status.rowsData = dataset;
status.rows = dataset;
};
const calcHeights = (onresize = false) => {
const { rowNum, dataset } = status.mergedConfig;
const avgHeight = h.value / rowNum;
status.avgHeight = avgHeight;
if (!onresize) status.heights = new Array(dataset.length).fill(avgHeight);
};
const animation = async (start = false) => {
let { avgHeight, animationIndex, mergedConfig, rowsData, updater } = status;
const { waitTime, carousel, rowNum } = mergedConfig;
const rowLength = rowsData.length;
if (rowNum >= rowLength) return;
if (start) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
if (updater !== status.updater) return;
}
const animationNum = carousel === 'single' ? 1 : rowNum;
let rows = rowsData.slice(animationIndex);
rows.push(...rowsData.slice(0, animationIndex));
status.rows = rows.slice(0, rowNum + 1);
status.heights = new Array(rowLength).fill(avgHeight);
await new Promise((resolve) => setTimeout(resolve, 300));
if (updater !== status.updater) return;
status.heights.splice(0, animationNum, ...new Array(animationNum).fill(0));
animationIndex += animationNum;
const back = animationIndex - rowLength;
if (back >= 0) animationIndex = back;
status.animationIndex = animationIndex;
status.animationHandler = setTimeout(animation, waitTime * 1000 - 300);
};
const stopAnimation = () => {
status.updater = (status.updater + 1) % 999999;
if (!status.animationHandler) return;
clearTimeout(status.animationHandler);
};
const onRestart = async () => {
try {
if (!status.mergedConfig) return;
let { dataset, rowNum, sort } = status.mergedConfig;
stopAnimation();
calcRowsData();
let flag = true;
if (dataset.length <= rowNum) {
flag = false;
}
calcHeights(flag);
animation(flag);
} catch (error) {
console.error(error);
}
};
onRestart();
watch(
() => w.value,
() => {
onRestart();
}
);
watch(
() => h.value,
() => {
onRestart();
}
);
watch(
() => rowNum.value,
() => {
onRestart();
}
);
//
watch(
() => props.chartConfig.option.dataset,
() => {
onRestart();
},
{
deep: false,
}
);
onUnmounted(() => {
stopAnimation();
});
</script>
<style lang="scss" scoped>
.go-tables-rank {
width: 100%;
height: 100%;
overflow: hidden;
.row-item {
transition: all 0.3s;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.ranking-info {
display: flex;
width: 100%;
font-size: 13px;
align-items: center;
.rank {
margin-right: 5px;
}
.info-name {
flex: 1;
}
}
.ranking-column {
border-bottom: 2px solid #1370fb80;
margin-top: 5px;
.inside-column {
position: relative;
height: 6px;
margin-bottom: 2px;
border-radius: 1px;
overflow: hidden;
}
.shine {
position: absolute;
left: 0%;
top: 2px;
height: 2px;
width: 50px;
transform: translateX(-100%);
background: radial-gradient(rgb(40, 248, 255) 5%, transparent 80%);
animation: shine 3s ease-in-out infinite alternate;
}
}
}
@keyframes shine {
80% {
left: 0;
transform: translateX(-100%);
}
100% {
left: 100%;
transform: translateX(0%);
}
}
</style>

View File

@ -0,0 +1,173 @@
<!--
* @Description:
* @Author: zenghua.wang
* @Date: 2024-03-24 11:04:52
* @LastEditors: zenghua.wang 1048523306@qq.com
* @LastEditTime: 2025-01-20 09:34:23
-->
<template>
<section class="rich-editor">
<Toolbar v-show="toolbarShow" class="rich-editor-toolbar" :editor="refEditor" :default-config="options.toolbarConfig" :mode="mode" />
<Editor
v-model="valueHtml"
class="rich-editor-toolbar"
:style="styleEditor"
:default-config="options.editorConfig"
:mode="mode"
@on-created="handleCreated"
@on-change="handleChange"
@on-destroyed="handleDestroyed"
@on-focus="handleFocus"
@on-blur="handleBlur"
/>
</section>
</template>
<script>
import '@wangeditor/editor/dist/css/style.css'; // css
import { shallowRef, ref, computed, nextTick, onBeforeUnmount, onMounted } from 'vue';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import { isEmpty } from '@/utils';
// import { CommonUpload, UploadImageFromEditor } from '@/apis/common';
import { imageUpload } from './upFile';
const { VITE_APP_OSS_URL } = import.meta.env;
export default {
name: 'CustomRichEditor',
components: { Editor, Toolbar },
props: {
value: {
type: String,
default: '',
},
mode: {
type: String,
default: 'default', //'default' 'simple'
},
readOnly: {
type: Boolean,
default: false,
},
options: {
type: Object,
default: () => {
return {
toolbarConfig: {},
editorConfig: {
placeholder: '请输入内容...',
readOnly: false,
MENU_CONF: {
uploadImage: {
server: '',
base64LimitSize: 10 * 1024, //10kb
maxFileSize: 10 * 1024 * 1024, //10M
maxNumberOfFiles: 10,
allowedFileTypes: ['image/*'],
customUpload: imageUpload,
},
},
},
};
},
},
toolbarShow: {
type: Boolean,
default: true,
},
},
emits: ['focus', 'blur', 'change', 'update:value'],
setup(props, cxt) {
const refEditor = shallowRef();
const valueHtml = ref('');
const styleEditor = computed(() => {
return {
height: props.options.contentHeight || '300px',
};
});
/**
* 创建
* @param {*} editor
*/
const handleCreated = (editor) => {
refEditor.value = editor;
props.readOnly ? editor.disable() : editor.enable();
};
/**
* 组件内容变化
* @param {*} editor
*/
const handleChange = (editor) => {
cxt.emit('change', editor);
cxt.emit('update:value', valueHtml.value);
};
/**
* 组件销毁
* @param {*} editor
*/
const handleDestroyed = (editor) => {
valueHtml.value = '';
};
/**
* 光标处于编辑区
* @param {*} editor
*/
const handleFocus = (editor) => {
cxt.emit('focus', editor);
};
/**
* 光标离开编辑区
* @param {*} editor
*/
const handleBlur = (editor) => {
cxt.emit('blur', editor);
};
/**
* 挂载
*/
onMounted(() => {
nextTick(() => {
if (props?.value) {
valueHtml.value = props.value;
}
});
});
/**
* 组件销毁时也及时销毁编辑器
*/
onBeforeUnmount(() => {
if (!refEditor?.value) return;
refEditor.value.destroy();
});
return {
refEditor,
valueHtml,
styleEditor,
handleCreated,
handleChange,
handleDestroyed,
handleFocus,
handleBlur,
};
},
};
</script>
<style lang="scss" scoped>
.rich-editor {
border: 1px solid $color-border;
z-index: 9999;
&-toolbar {
border-bottom: 1px solid $color-border;
:deep(.w-e-bar-divider) {
width: 0;
}
}
&-editor {
overflow-y: hidden;
height: 200px;
}
}
</style>

View File

@ -0,0 +1,10 @@
import { CommonUpload } from '../../apis/common';
export async function imageUpload(file, insertFn) {
let formData = new FormData();
formData.append('file', file);
let res = await CommonUpload(formData);
if (res.code == 200) {
insertFn(res.data.url, file.name, res.data.key);
}
}

View File

@ -0,0 +1,366 @@
<template>
<div class="dv-scroll-board">
<div v-if="status.header.length && status.mergedConfig" class="header" :style="`background-color: ${status.mergedConfig.headerBGC};`">
<div
v-for="(headerItem, i) in status.header"
:key="`${headerItem}${i}`"
class="header-item"
:style="`
height: ${status.mergedConfig.headerHeight}px;
line-height: ${status.mergedConfig.headerHeight}px;
width: ${status.widths[i]}px;
`"
:align="status.aligns[i]"
v-html="headerItem"
></div>
</div>
<div v-if="status.mergedConfig" class="rows" :style="`height: ${h - (status.header.length ? status.mergedConfig.headerHeight : 0)}px;`">
<div
v-for="(row, ri) in status.rows"
:key="`${row.toString()}${row.scroll}`"
class="row-item"
:style="`
height: ${status.heights[ri]}px;
line-height: ${status.heights[ri]}px;
background-color: ${status.mergedConfig[row.rowIndex % 2 === 0 ? 'evenRowBGC' : 'oddRowBGC']};
`"
>
<div
v-for="(ceil, ci) in row.ceils"
:key="`${ceil}${ri}${ci}`"
class="ceil"
:style="`width: ${status.widths[ci]}px;`"
:align="status.aligns[ci]"
v-html="ceil"
></div>
</div>
</div>
</div>
</template>
<script setup name="custom-scroll-board">
import { onUnmounted, reactive, toRefs, watch, onMounted } from 'vue';
import merge from 'lodash/merge';
import cloneDeep from 'lodash/cloneDeep';
const props = defineProps({
chartConfig: {
type: Object,
required: true,
},
});
const { w, h } = toRefs(props.chartConfig.attr);
const status = reactive({
defaultConfig: {
/**
* @description Board header
* @type {Array<String>}
* @default header = []
* @example header = ['column1', 'column2', 'column3']
*/
header: [],
/**
* @description Board dataset
* @type {Array<Array>}
* @default dataset = []
*/
dataset: [],
/**
* @description Row num
* @type {Number}
* @default rowNum = 5
*/
rowNum: 5,
/**
* @description Header background color
* @type {String}
* @default headerBGC = '#00BAFF'
*/
headerBGC: '#00BAFF',
/**
* @description Odd row background color
* @type {String}
* @default oddRowBGC = '#003B51'
*/
oddRowBGC: '#003B51',
/**
* @description Even row background color
* @type {String}
* @default evenRowBGC = '#003B51'
*/
evenRowBGC: '#0A2732',
/**
* @description Scroll wait time
* @type {Number}
* @default waitTime = 2
*/
waitTime: 2,
/**
* @description Header height
* @type {Number}
* @default headerHeight = 35
*/
headerHeight: 35,
/**
* @description Column width
* @type {Array<Number>}
* @default columnWidth = []
*/
columnWidth: [],
/**
* @description Column align
* @type {Array<String>}
* @default align = []
* @example align = ['left', 'center', 'right']
*/
align: [],
/**
* @description Show index
* @type {Boolean}
* @default index = false
*/
index: false,
/**
* @description index Header
* @type {String}
* @default indexHeader = '#'
*/
indexHeader: '#',
/**
* @description Carousel type
* @type {String}
* @default carousel = 'single'
* @example carousel = 'single' | 'page'
*/
carousel: 'single',
/**
* @description Pause scroll when mouse hovered
* @type {Boolean}
* @default hoverPause = true
* @example hoverPause = true | false
*/
hoverPause: true,
},
mergedConfig: props.chartConfig.option,
header: [],
rowsData: [],
rows: [
{
ceils: [],
rowIndex: 0,
scroll: 0,
},
],
widths: [],
heights: [0],
avgHeight: 0,
aligns: [],
animationIndex: 0,
animationHandler: 0,
updater: 0,
needCalc: false,
});
const calcData = () => {
mergeConfig();
calcHeaderData();
calcRowsData();
calcWidths();
calcHeights();
calcAligns();
animation(true);
};
onMounted(() => {
calcData();
});
const mergeConfig = () => {
status.mergedConfig = merge(cloneDeep(status.defaultConfig), props.chartConfig.option);
};
const calcHeaderData = () => {
let { header, index, indexHeader } = status.mergedConfig;
if (!header.length) {
status.header = [];
return;
}
header = [...header];
if (index) header.unshift(indexHeader);
status.header = header;
};
const calcRowsData = () => {
let { dataset, index, headerBGC, rowNum } = status.mergedConfig;
if (index) {
dataset = dataset.map((row, i) => {
row = [...row];
const indexTag = `<span class="index" style="background-color: ${headerBGC};border-radius: 3px;padding: 0px 3px;">${i + 1}</span>`;
row.unshift(indexTag);
return row;
});
}
dataset = dataset.map((ceils, i) => ({ ceils, rowIndex: i }));
const rowLength = dataset.length;
if (rowLength > rowNum && rowLength < 2 * rowNum) {
dataset = [...dataset, ...dataset];
}
dataset = dataset.map((d, i) => ({ ...d, scroll: i }));
status.rowsData = dataset;
status.rows = dataset;
};
const calcWidths = () => {
const { mergedConfig, rowsData } = status;
const { columnWidth, header } = mergedConfig;
const usedWidth = columnWidth.reduce((all, ws) => all + ws, 0);
let columnNum = 0;
if (rowsData[0]) {
columnNum = rowsData[0].ceils.length;
} else if (header.length) {
columnNum = header.length;
}
const avgWidth = (w.value - usedWidth) / (columnNum - columnWidth.length);
const widths = new Array(columnNum).fill(avgWidth);
status.widths = merge(widths, columnWidth);
};
const calcHeights = (onresize = false) => {
const { mergedConfig, header } = status;
const { headerHeight, rowNum, dataset } = mergedConfig;
let allHeight = h.value;
if (header.length) allHeight -= headerHeight;
const avgHeight = allHeight / rowNum;
status.avgHeight = avgHeight;
if (!onresize) status.heights = new Array(dataset.length).fill(avgHeight);
};
const calcAligns = () => {
const { header, mergedConfig } = status;
const columnNum = header.length;
let aligns = new Array(columnNum).fill('left');
const { align } = mergedConfig;
status.aligns = merge(aligns, align);
};
const animation = async (start = false) => {
const { needCalc } = status;
if (needCalc) {
calcRowsData();
calcHeights();
status.needCalc = false;
}
let { avgHeight, animationIndex, mergedConfig, rowsData, updater } = status;
const { waitTime, carousel, rowNum } = mergedConfig;
const rowLength = rowsData.length;
if (rowNum >= rowLength) return;
if (start) {
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
if (updater !== status.updater) return;
}
const animationNum = carousel === 'single' ? 1 : rowNum;
let rows = rowsData.slice(animationIndex);
rows.push(...rowsData.slice(0, animationIndex));
status.rows = rows.slice(0, carousel === 'page' ? rowNum * 2 : rowNum + 1);
status.heights = new Array(rowLength).fill(avgHeight);
await new Promise((resolve) => setTimeout(resolve, 300));
if (updater !== status.updater) return;
status.heights.splice(0, animationNum, ...new Array(animationNum).fill(0));
animationIndex += animationNum;
const back = animationIndex - rowLength;
if (back >= 0) animationIndex = back;
status.animationIndex = animationIndex;
status.animationHandler = setTimeout(animation, waitTime * 1000 - 300);
};
const stopAnimation = () => {
status.updater = (status.updater + 1) % 999999;
if (!status.animationHandler) return;
clearTimeout(status.animationHandler);
};
const onRestart = async () => {
try {
if (!status.mergedConfig) return;
stopAnimation();
calcData();
} catch (error) {
console.log(error);
}
};
watch(
() => w.value,
() => {
onRestart();
}
);
watch(
() => h.value,
() => {
onRestart();
}
);
//
watch(
() => props.chartConfig.option,
() => {
onRestart();
},
{ deep: true }
);
onUnmounted(() => {
stopAnimation();
});
</script>
<style lang="scss" scoped>
.dv-scroll-board {
position: relative;
width: 100%;
height: 100%;
color: #fff;
.text {
padding: 0 10px;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header {
display: flex;
flex-direction: row;
font-size: 15px;
.header-item {
transition: all 0.3s;
}
}
.rows {
overflow: hidden;
.row-item {
display: flex;
font-size: 14px;
transition: all 0.3s;
overflow: hidden;
}
}
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<el-dropdown popper-class="custom-table-operate">
<el-icon class="custom-table-operate__more">
<template v-if="show">
<More />
</template>
</el-icon>
<template v-if="show" #dropdown>
<el-dropdown-menu v-if="!isEmpty(actions)">
<template v-for="item in actions" :key="item.name">
<el-dropdown-item v-if="onPermission(item)" @click="item.event(data)">
<el-button :type="item.type ?? 'primary'" :icon="formatterIcon(item)" :size="item.size" :disabled="item.disabled" text>
{{ formatterName(item) }}
</el-button>
</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup name="custom-table-operate">
import { isEmpty } from '@/utils';
const props = defineProps({
actions: { type: Array, default: () => [] },
data: { type: Object, default: () => {} },
show: { type: Boolean, default: true },
});
const onPermission = (row) => {
if (row.auth === undefined) return true;
return typeof row.auth === 'function' ? row.auth(props.data) : row.auth;
};
const formatterName = (row) => {
return typeof row.name === 'function' ? row.name(props.data) : row.name;
};
const formatterIcon = (row) => {
return typeof row.icon === 'function' ? row.icon(props.data) : row.icon;
};
</script>
<style lang="scss" scoped>
.custom-table-operate {
&__more {
padding: 20px 5px;
font-size: 20px;
color: var(--el-color-primary);
cursor: pointer;
}
.el-button {
&,
&:hover &.is-text:hover,
&.is-text:not(.is-disabled):hover {
background: none !important;
}
}
}
</style>

View File

@ -0,0 +1,120 @@
<template>
<div :class="`custom-table-tree ${shadow ? 'custom-table-tree__shadow' : ''}`">
<div v-if="title" class="title">{{ title }}</div>
<div class="panel">
<el-input v-if="filter" v-model="state.keyword" clearable placeholder="请输入关键字筛选" class="panel-filter" />
<el-tree
ref="treeRef"
:data="state.list"
:node-key="option.nodeKey"
:show-checkbox="option.showCheckbox"
:default-expanded-keys="option.defaultExpandedKeys"
:default-checked-keys="option.defaultCheckedKeys"
:default-expand-all="option.defaultExpandAll"
:props="option.props ?? option.defaultProps"
:filter-node-method="filterNodeMethod"
@node-click="handleNodeClick"
>
<template #default="{ data: rows }">
<slot :data="rows"></slot>
</template>
</el-tree>
</div>
</div>
</template>
<script setup name="custom-table-tree">
import { reactive, ref, watch } from 'vue';
const props = defineProps({
title: { type: String, default: '' },
shadow: { type: Boolean, default: false },
filter: { type: Boolean, default: false },
data: { type: Array, default: () => [] },
option: {
type: Object,
default: () => {
return {
nodeKey: 'id',
showCheckbox: false,
props: {},
defaultProps: {
children: 'children',
label: 'label',
},
defaultExpandedKeys: [],
defaultCheckedKeys: [],
defaultExpandAll: false,
};
},
},
});
const emit = defineEmits(['node-click']);
const treeRef = ref(null);
const state = reactive({
keyword: '',
list: [],
});
const label = props.option.props?.label ?? 'label';
watch(
() => props.data,
(val) => {
state.list = val;
},
{
immediate: true,
}
);
watch(
() => state.keyword,
(val) => {
treeRef.value.filter(val);
}
);
const filterNodeMethod = (value, data) => {
if (!value) return true;
return data[label].includes(value);
};
const handleNodeClick = (data, node) => {
emit('node-click', data, node);
};
</script>
<style lang="scss" scoped>
.custom-table-tree {
width: 100%;
height: 100%;
min-height: 400px;
min-width: 200px;
@include flex-column();
&__shadow {
box-shadow: 2px 0px 5px rgba(0, 0, 0, 0.1);
}
.title {
height: 36px;
padding: 0 20px;
line-height: 36px;
border-radius: 4px;
font-size: 16px;
color: #fff;
background: var(--el-color-primary);
}
.panel {
padding: 16px 10px 10px;
&-filter {
margin-bottom: 10px;
}
}
.el-tree {
box-sizing: border-box;
}
}
</style>

45
src/components/index.js Normal file
View File

@ -0,0 +1,45 @@
import SvgIcon from './svg-icon';
import CustomIframe from './custom-iframe';
import CustomTableOperate from './custom-table-operate';
import CustomTableTree from './custom-table-tree';
import CustomCarouselPicture from './custom-carousel-picture';
import CustomImportExcel from './custom-import-excel';
import CustomRichEditor from './custom-rich-editor';
import CustomEchartBar from './custom-echart-bar';
import CustomEchartPie from './custom-echart-pie';
import CustomEchartLine from './custom-echart-line';
import CustomEchartRadar from './custom-echart-radar';
import CustomEchartMixin from './custom-echart-mixin';
import customEchartPictorialBar from './custom-echart-pictorial-bar';
import CustomEchartLineLine from './custom-echart-line-line';
import CustomEchartBubble from './custom-echart-bubble';
import CustomEchartPie3d from './custom-echart-pie-3d';
import CustomEchartWaterDroplet from './custom-echart-water-droplet';
import CustomEchartPieGauge from './custom-echart-pie-gauge';
import CustomEchartWordCloud from './custom-echart-word-cloud';
import customEchartScatterBlister from './custom-echart-scatter-blister';
import customEchartMaps from './custom-echart-maps';
export {
SvgIcon,
CustomIframe,
CustomTableOperate,
CustomTableTree,
CustomCarouselPicture,
CustomImportExcel,
CustomRichEditor,
CustomEchartBar,
CustomEchartPie,
CustomEchartLine,
CustomEchartRadar,
CustomEchartMixin,
customEchartPictorialBar,
CustomEchartLineLine,
CustomEchartBubble,
CustomEchartPie3d,
CustomEchartWaterDroplet,
CustomEchartPieGauge,
CustomEchartWordCloud,
customEchartScatterBlister,
customEchartMaps,
};

View File

@ -0,0 +1,81 @@
<template>
<div v-if="isExternal" :style="styleExternalIcon" class="svg-external-icon svg-icon" />
<svg v-else :class="svgClass" aria-hidden="true" :style="styleSize()">
<use :xlink:href="iconName" />
<title v-if="title">{{ title }}</title>
</svg>
</template>
<script setup name="svg-icon">
import { computed } from 'vue';
import { setPx } from '@/utils';
const props = defineProps({
// svg
name: {
type: String,
required: true,
},
size: {
type: [Number, String],
default: 16,
},
// class,svgclass,svg
className: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
});
//
const isExternal = computed(() => {
return /^(https?:|mailto:|tel:)/.test(props.name);
});
//svg
const iconName = computed(() => {
return `#icon-${props.name}`;
});
//svg
const svgClass = computed(() => {
if (props.className) {
return 'svg-icon ' + props.className;
} else {
return 'svg-icon';
}
});
const styleSize = () => {
return {
fontSize: setPx(props.size),
};
};
const styleExternalIcon = computed(() => {
return {
mask: `url(${props.name}) no-repeat 50% 50%`,
'-webkit-mask': `url(${props.name}) no-repeat 50% 50%`,
};
});
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.svg-external-icon {
background-color: currentColor;
mask-size: cover !important;
display: inline-block;
}
</style>

10
src/config/index.js Normal file
View File

@ -0,0 +1,10 @@
const { VITE_APP_NAME } = import.meta.env;
export const GenKey = (key, prefix = VITE_APP_NAME) => {
return prefix ? `${prefix}_` + key : key;
};
export const CONSTANTS = {
PREFIX: `${VITE_APP_NAME}_`,
PRIMARY: '#409eff',
};

32
src/directives/auth.js Normal file
View File

@ -0,0 +1,32 @@
/**
* @Description: 按钮权限
* @Author: zenghua.wang
* @Date: 2022-08-30 09:42:47
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-01-30 13:59:50
*/
// import { CACHE_KEY, useCache } from '@/hooks/web/useCache';
export function useAuth(app) {
app.directive('auth', (el, binding) => {
// const { wsCache } = useCache();
const { value } = binding;
const all_permission = '*:*:*';
const permissions = []; //wsCache.get(CACHE_KEY.USER).permissions;
if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value;
const hasAuth = permissions.some((permission) => {
return all_permission === permission || permissionFlag.includes(permission);
});
if (!hasAuth) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
// throw new Error('no auth to access it.');
}
});
}

9
src/directives/index.js Normal file
View File

@ -0,0 +1,9 @@
import { useAuth } from './auth';
/**
* 指令v-xxx
* @methods auth 按钮权限用法: v-auth
*/
export const registerDirective = (app) => {
useAuth(app);
};

5
src/hooks/index.js Normal file
View File

@ -0,0 +1,5 @@
import { getCurrentInstance } from 'vue';
export const useApp = () => {
return getCurrentInstance().appContext?.config?.globalProperties;
};

View File

@ -0,0 +1,84 @@
import { ref, computed, unref } from 'vue';
import { useEventListener } from './useEventListener';
let globalScreenRef = 0;
let globalWidthRef = 0;
let globalRealWidthRef = 0;
const screenMap = new Map();
screenMap.set('XS', 480);
screenMap.set('SM', 576);
screenMap.set('MD', 768);
screenMap.set('LG', 992);
screenMap.set('XL', 1200);
screenMap.set('XXL', 1600);
export function useBreakpoint() {
return {
screenRef: computed(() => unref(globalScreenRef)),
widthRef: globalWidthRef,
realWidthRef: globalRealWidthRef,
};
}
// Just call it once
export function createBreakpointListen(fn) {
const screenRef = ref('XL');
const realWidthRef = ref(window.innerWidth);
function getWindowWidth() {
const width = document.body.clientWidth;
const xs = screenMap.get('XS');
const sm = screenMap.get('SM');
const md = screenMap.get('MD');
const lg = screenMap.get('LG');
const xl = screenMap.get('XL');
const xxl = screenMap.set('XXL', 1600);
if (width < xs) {
screenRef.value = xs;
} else if (width < sm) {
screenRef.value = sm;
} else if (width < md) {
screenRef.value = md;
} else if (width < lg) {
screenRef.value = lg;
} else if (width < xl) {
screenRef.value = xl;
} else {
screenRef.value = xxl;
}
realWidthRef.value = width;
}
useEventListener({
el: window,
name: 'resize',
listener: () => {
getWindowWidth();
resizeFn();
},
// wait: 100,
});
getWindowWidth();
globalScreenRef = computed(() => unref(screenRef));
globalWidthRef = computed(() => screenMap.get(unref(screenRef)));
globalRealWidthRef = computed(() => unref(realWidthRef));
function resizeFn() {
fn?.({
screen: globalScreenRef,
width: globalWidthRef,
realWidth: globalRealWidthRef,
screenMap,
});
}
resizeFn();
return {
screenRef: globalScreenRef,
widthRef: globalWidthRef,
realWidthRef: globalRealWidthRef,
};
}

121
src/hooks/useEcharts.js Normal file
View File

@ -0,0 +1,121 @@
import { unref, nextTick, watch, computed, ref } from 'vue';
import { useDebounceFn, tryOnUnmounted } from '@vueuse/core';
import { useTimeoutFn } from './useTimeout';
import { useEventListener } from './useEventListener';
import { useBreakpoint } from './useBreakpoint';
import echarts from '../utils/echarts';
export const useEcharts = (elRef, theme = 'default') => {
const getDarkMode = computed(() => {
return theme === 'default' ? 'dark' : theme;
});
let chartInstance = null;
let resizeFn = resize;
const cacheOptions = ref({});
let removeResizeFn = null;
resizeFn = useDebounceFn(resize, 200);
const getOptions = computed(() => {
if (getDarkMode.value !== 'dark') {
return cacheOptions.value;
}
return {
backgroundColor: 'transparent',
...cacheOptions.value,
};
});
function initCharts(t = theme) {
const el = unref(elRef);
if (!el || !unref(el)) {
return;
}
chartInstance = echarts.init(el, t);
const { removeEvent } = useEventListener({
el: window,
name: 'resize',
listener: resizeFn,
});
removeResizeFn = removeEvent;
const { widthRef } = useBreakpoint();
if (unref(widthRef) <= 768 || el.offsetHeight === 0) {
useTimeoutFn(() => {
resizeFn();
}, 30);
}
}
function setOptions(options = {}, clear = true) {
cacheOptions.value = options;
if (unref(elRef)?.offsetHeight === 0) {
useTimeoutFn(() => {
setOptions(unref(getOptions));
}, 30);
return;
}
nextTick(() => {
useTimeoutFn(() => {
if (!chartInstance) {
initCharts(getDarkMode.value ?? 'default');
if (!chartInstance) return;
}
clear && chartInstance?.clear();
chartInstance?.setOption(unref(getOptions));
}, 30);
});
}
function resize() {
chartInstance?.resize();
}
/**
* 注册地图数据
* @param {string} mapName - 地图名称
* @param {object} geoJSON - GeoJSON 数据
*/
function registerMap(mapName, geoJSON) {
if (!mapName || !geoJSON) {
console.warn('地图名称或 GeoJSON 数据无效');
return;
}
echarts.registerMap(mapName, geoJSON);
}
watch(
() => getDarkMode.value,
(theme) => {
if (chartInstance) {
chartInstance.dispose();
initCharts(theme);
setOptions(cacheOptions.value);
}
}
);
tryOnUnmounted(() => {
if (!chartInstance) return;
removeResizeFn();
chartInstance.dispose();
chartInstance = null;
});
function getInstance() {
if (!chartInstance) {
initCharts(getDarkMode.value ?? 'default');
}
return chartInstance;
}
return {
setOptions,
resize,
echarts,
getInstance,
registerMap,
};
};

View File

@ -0,0 +1,38 @@
import { ref, watch, unref } from 'vue';
import { useThrottleFn, useDebounceFn } from '@vueuse/core';
export function useEventListener({ el = window, name, listener, options, autoRemove = true, isDebounce = true, wait = 80 }) {
let remove;
const isAddRef = ref(false);
if (el) {
const element = ref(el);
const handler = isDebounce ? useDebounceFn(listener, wait) : useThrottleFn(listener, wait);
const realHandler = wait ? handler : listener;
const removeEventListener = (e) => {
isAddRef.value = true;
e.removeEventListener(name, realHandler, options);
};
const addEventListener = (e) => e.addEventListener(name, realHandler, options);
const removeWatch = watch(
element,
(v, _ov, cleanUp) => {
if (v) {
!unref(isAddRef) && addEventListener(v);
cleanUp(() => {
autoRemove && removeEventListener(v);
});
}
},
{ immediate: true }
);
remove = () => {
removeEventListener(element.value);
removeWatch();
};
}
return { removeEvent: remove };
}

44
src/hooks/useTimeout.js Normal file
View File

@ -0,0 +1,44 @@
import { ref, watch } from 'vue';
import { tryOnUnmounted } from '@vueuse/core';
export function useTimeoutFn(handle, wait, native = false) {
if (typeof handle !== 'function') {
throw new Error('handle is not Function!');
}
const { readyRef, stop, start } = useTimeoutRef(wait);
if (native) {
handle();
} else {
watch(
readyRef,
(maturity) => {
maturity && handle();
},
{ immediate: false }
);
}
return { readyRef, stop, start };
}
export function useTimeoutRef(wait) {
const readyRef = ref(false);
let timer;
function stop() {
readyRef.value = false;
timer && window.clearTimeout(timer);
}
function start() {
stop();
timer = setTimeout(() => {
readyRef.value = true;
}, wait);
}
start();
tryOnUnmounted(stop);
return { readyRef, stop, start };
}

View File

@ -0,0 +1,22 @@
import { h } from 'vue';
const wrapperMap = new Map();
export const useWrapComponents = (Component, route) => {
let wrapper;
if (Component) {
const wrapperName = route.name;
if (wrapperMap.has(wrapperName)) {
wrapper = wrapperMap.get(wrapperName);
} else {
wrapper = {
name: wrapperName,
render() {
return h('div', { className: 'layout' }, Component);
},
};
wrapperMap.set(wrapperName, wrapper);
}
return h(wrapper);
}
};

4
src/layouts/Views.vue Normal file
View File

@ -0,0 +1,4 @@
<template>
<router-view />
</template>
<script setup name="layout-views"></script>

3
src/layouts/index.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

16
src/main.js Normal file
View File

@ -0,0 +1,16 @@
import 'virtual:svg-icons-register';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import pinia from './store';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import 'animate.css';
import './utils/permission';
import { registerDirective } from './directives';
import { registerElIcons } from './plugins/icon';
const app = createApp(App);
registerElIcons(app);
registerDirective(app);
app.use(pinia).use(router).use(ElementPlus).mount('#app');

8
src/plugins/icon.js Normal file
View File

@ -0,0 +1,8 @@
import * as ElIconsModules from '@element-plus/icons-vue';
// 全局注册element-plus icon图标组件
export const registerElIcons = (app) => {
Object.keys(ElIconsModules).forEach((key) => {
app.component(key, ElIconsModules[key]);
});
};

51
src/router/index.js Normal file
View File

@ -0,0 +1,51 @@
import { createRouter, createWebHistory } from 'vue-router';
// import Layout from '@/layouts/index.vue';
import demoRouters from './modules/demo';
export const constantRoutes = [
// {
// path: '/404',
// name: '404',
// component: () => import('@/views/error/404.vue'),
// hidden: true,
// },
// {
// path: '/403',
// name: '403',
// component: () => import('@/views/error/403.vue'),
// hidden: true,
// },
// {
// path: '/',
// name: 'layout',
// component: Layout,
// redirect: '/home',
// meta: { title: '首页', icon: 'House' },
// children: [
// {
// path: '/home',
// component: () => import('@/views/home/index.vue'),
// name: 'home',
// meta: { title: '首页', icon: 'House' },
// },
// ],
// },
...demoRouters,
];
/**
* @Title notFoundRouter(找不到路由)
*/
export const notFoundRouter = {
path: '/:pathMatch(.*)',
name: 'notFound',
redirect: '/404',
};
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes,
});
export default router;

View File

@ -0,0 +1,14 @@
export default [
{
path: '/scrollBoard',
name: 'scrollBoard',
component: () => import('@/views/demo/scrollBoard.vue'),
meta: { title: '轮播列表', icon: 'document' },
},
{
path: '/rank',
name: 'rank',
component: () => import('@/views/demo/rank.vue'),
meta: { title: '滚动排名列表', icon: 'document' },
},
];

20
src/store/index.js Normal file
View File

@ -0,0 +1,20 @@
import { defineStore, createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const { VITE_APP_NAME } = import.meta.env;
export const Store = defineStore({
id: VITE_APP_NAME,
state: () => ({}),
getters: {},
actions: {},
persist: {
key: VITE_APP_NAME,
storage: window.sessionStorage,
},
});
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export default pinia;

View File

@ -0,0 +1,53 @@
import { defineStore } from 'pinia';
import { constantRoutes, notFoundRouter } from '@/router';
import { createAsyncRoutes, filterAsyncRoutes, filterKeepAlive } from '@/utils/router';
import { useUserStore } from '@/store/modules/user';
import { getTree } from '@/utils';
import { GenKey } from '@/config';
export const usePermissionStore = defineStore({
id: GenKey('permissionStore'),
state: () => ({
// 路由
routes: [],
// 动态路由
asyncRoutes: [],
// 缓存路由
cacheRoutes: {},
}),
getters: {
permissionRoutes: (state) => {
return state.routes;
},
keepAliveRoutes: (state) => {
return filterKeepAlive(state.asyncRoutes);
},
},
actions: {
generateRoutes(roles) {
return new Promise((resolve) => {
// 在这判断是否有权限,哪些角色拥有哪些权限
const UserStore = useUserStore();
this.asyncRoutes = createAsyncRoutes(getTree(UserStore.getMenus()));
let accessedRoutes;
if (roles && roles.length && !roles.includes('admin')) {
accessedRoutes = filterAsyncRoutes(this.asyncRoutes, roles);
} else {
accessedRoutes = this.asyncRoutes || [];
}
accessedRoutes = accessedRoutes.concat(notFoundRouter);
this.routes = constantRoutes.concat(accessedRoutes);
resolve(accessedRoutes);
});
},
clearRoutes() {
this.routes = [];
this.asyncRoutes = [];
this.cacheRoutes = [];
},
getCacheRoutes() {
this.cacheRoutes = filterKeepAlive(this.asyncRoutes);
return this.cacheRoutes;
},
},
});

View File

@ -0,0 +1,70 @@
import { defineStore } from 'pinia';
import { CONSTANTS } from '@/config';
import { GenKey } from '@/config';
export const useSettingStore = defineStore({
id: GenKey('settingStore'),
state: () => ({
// menu 是否收缩
isCollapse: true,
//
withoutAnimation: false,
device: 'desktop',
// 刷新当前页
isReload: true,
// 主题设置
themeConfig: {
// 显示设置
showSetting: false,
// 菜单展示模式 默认 vertical horizontal / vertical /columns
mode: 'vertical',
// tagsView 是否展示 默认展示
showTag: true,
// 页脚
footer: true,
// 深色模式 切换暗黑模式
isDark: false,
// 显示侧边栏Logo
showLogo: true,
// 主题颜色
primary: CONSTANTS.PRIMARY,
// element组件大小
globalComSize: 'default',
// 是否只保持一个子菜单的展开
uniqueOpened: true,
// 固定header
fixedHeader: true,
// 灰色模式
gray: false,
// 色弱模式
weak: false,
},
}),
getters: {},
actions: {
// 设置主题
setThemeConfig({ key, val }) {
this.themeConfig[key] = val;
},
// 切换 Collapse
setCollapse(value) {
this.isCollapse = value;
this.withoutAnimation = false;
},
// 关闭侧边栏
closeSideBar({ withoutAnimation }) {
this.isCollapse = false;
this.withoutAnimation = withoutAnimation;
},
toggleDevice(device) {
this.device = device;
},
// 刷新
setReload() {
this.isReload = false;
setTimeout(() => {
this.isReload = true;
}, 50);
},
},
});

View File

@ -0,0 +1,105 @@
import { defineStore } from 'pinia';
import { GenKey } from '@/config';
import router from '@/router';
export const useTagsViewStore = defineStore({
id: GenKey('tagsViewStore'),
state: () => ({
activeTabsValue: '/home',
visitedViews: [],
cachedViews: [],
}),
getters: {},
actions: {
setTabsMenuValue(val) {
this.activeTabsValue = val;
},
addView(view) {
this.addVisitedView(view);
},
removeView(routes) {
return new Promise((resolve) => {
this.visitedViews = this.visitedViews.filter((item) => !routes.includes(item.path));
resolve(null);
});
},
addVisitedView(view) {
this.setTabsMenuValue(view.path);
if (this.visitedViews.some((v) => v.path === view.path)) return;
this.visitedViews.push(
Object.assign({}, view, {
title: view.meta.title || 'no-name',
})
);
if (view.meta.keepAlive) {
this.cachedViews.push(view.name);
}
},
delView(activeTabPath) {
return new Promise((resolve) => {
this.delVisitedView(activeTabPath);
this.delCachedView(activeTabPath);
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews],
});
});
},
toLastView(activeTabPath) {
const index = this.visitedViews.findIndex((item) => item.path === activeTabPath);
const nextTab = this.visitedViews[index + 1] || this.visitedViews[index - 1];
if (!nextTab) return;
router.push(nextTab.path);
this.addVisitedView(nextTab);
},
delVisitedView(path) {
return new Promise((resolve) => {
this.visitedViews = this.visitedViews.filter((v) => {
return v.path !== path || v.meta.affix;
});
this.cachedViews = this.cachedViews.filter((v) => {
return v.path !== path || v.meta.affix;
});
resolve([...this.visitedViews]);
});
},
delCachedView(view) {
return new Promise((resolve) => {
const index = this.cachedViews.indexOf(view.name);
index > -1 && this.cachedViews.splice(index, 1);
resolve([...this.cachedViews]);
});
},
clearVisitedView() {
this.delAllViews();
},
delAllViews() {
return new Promise((resolve) => {
this.visitedViews = this.visitedViews.filter((v) => v.meta.affix);
this.cachedViews = this.visitedViews.filter((v) => v.meta.affix);
resolve([...this.visitedViews]);
});
},
delOtherViews(path) {
this.visitedViews = this.visitedViews.filter((item) => {
return item.path === path || item.meta.affix;
});
this.cachedViews = this.visitedViews.filter((item) => {
return item.path === path || item.meta.affix;
});
},
goHome() {
this.activeTabsValue = '/home';
router.push({ path: '/home' });
},
updateVisitedView(view) {
for (let v of this.visitedViews) {
if (v.path === view.path) {
v = Object.assign(v, view);
break;
}
}
},
},
});

65
src/store/modules/user.js Normal file
View File

@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
import { GenKey } from '@/config';
import { isEmpty, encode, decode } from '@/utils';
export const useUserStore = defineStore({
id: GenKey('userStore'),
state: () => ({
token: null,
userInfo: {},
currentOrg: null,
orgList: [],
menus: [],
}),
getters: {},
actions: {
setToken(token) {
this.token = token;
},
hasToken() {
return !isEmpty(this.token);
},
setUserInfo(userInfo) {
this.userInfo = encode(JSON.stringify(userInfo), true);
},
getUserInfo() {
return !isEmpty(this.userInfo) ? JSON.parse(decode(this.userInfo, true)) : {};
},
setOrgList(orgList) {
this.orgList = encode(JSON.stringify(orgList), true);
},
getOrgList() {
return !isEmpty(this.orgList) ? JSON.parse(decode(this.orgList, true)) : [];
},
setCurrentOrg(org) {
this.currentOrg = org;
},
getCurrentOrg() {
const list = this.getOrgList().filter((item) => {
return item.id === this.currentOrg;
});
return !isEmpty(list) ? list[0] : {};
},
setMenus(menus) {
this.menus = encode(JSON.stringify(menus), true);
},
getMenus() {
return !isEmpty(this.menus) ? JSON.parse(decode(this.menus, true)) : [];
},
logout() {
this.token = null;
this.userInfo = {};
this.currentOrg = null;
this.orgList = [];
this.menus = [];
localStorage.removeItem(GenKey('userStore'));
},
clear() {
localStorage.removeItem(GenKey('userStore'));
},
},
persist: {
key: GenKey('userStore'),
storage: window.localStorage,
},
});

283
src/styles/common/base.scss Normal file
View File

@ -0,0 +1,283 @@
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
border: 0;
text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
a,
abbr,
acronym,
address,
article,
aside,
blockquote,
caption,
code,
del,
dfn,
dialog,
header,
footer,
nav,
object,
section,
body,
dd,
div,
dl,
dt,
em,
img,
fieldset,
figure,
form,
h1,
h2,
h3,
h4,
h5,
h6,
hgroup,
iframe,
legend,
p,
pre,
q,
span,
tbody,
tfoot,
thead,
ul,
ol,
li {
margin: 0;
padding: 0;
border: 0;
list-style: none;
vertical-align: baseline;
}
article,
aside,
details,
dialog,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
width: 100%;
height: 100%;
font-size: 12px;
font-family: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', 'Hiragino Sans GB', 'Heiti SC', 'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif;
color: #323232;
// background: #000;
}
img {
vertical-align: bottom;
border: 0;
}
// ::input-placeholder {
// color: #999999;
// }
::placeholder {
color: #999999;
}
// :input-placeholder {
// color: #cccccc;
// }
button::-moz-focus-inner,
input::-moz-focus-inner {
padding: 0;
border: 0;
}
textarea {
overflow: auto;
}
button:focus,
input:focus,
select:focus,
textarea:focus {
outline: 0;
}
input::-ms-clear {
display: none;
}
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
nav,
section,
summary {
display: block;
}
audio,
canvas,
progress,
video {
display: inline-block;
vertical-align: baseline;
}
audio:not([controls]) {
display: none;
height: 0;
}
[hidden],
template {
display: none;
}
a {
text-decoration: none;
color: #323232;
background: none;
cursor: pointer;
}
a:active,
a:hover {
outline: 0;
}
a:focus {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px;
}
abbr[title] {
border-bottom: 1px dotted;
}
b,
strong {
font-weight: 700;
}
dfn {
font-style: italic;
}
mark {
color: #000000;
background: #ffff00;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
svg:not(:root) {
overflow: hidden;
}
figure {
margin: 1em 40px;
}
hr {
box-sizing: content-box;
height: 0;
}
pre {
overflow: auto;
}
code,
kbd,
pre,
samp {
font-size: 1em;
font-family: monospace;
}
button,
input,
optgroup,
select,
textarea {
margin: 0;
font: inherit;
}
button {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
html input[type='button'],
input[type='reset'],
input[type='submit'] {
appearance: button;
cursor: pointer;
}
button[disabled],
html input[disabled] {
cursor: default;
}
button::-moz-focus-inner,
input::-moz-focus-inner {
padding: 0;
border: 0;
}
input {
margin: 0;
padding: 0;
line-height: normal;
}
input[type='checkbox'],
input[type='radio'] {
box-sizing: border-box;
padding: 0;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
height: auto;
}
input[type='search'] {
box-sizing: content-box;
appearance: textfield;
}
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-decoration {
appearance: none;
}
fieldset {
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
border: 1px solid silver;
}
legend {
padding: 0;
border: 0;
}
optgroup {
font-weight: 700;
}
table {
border-spacing: 0;
border-collapse: collapse;
}
td,
th {
padding: 0;
}

View File

@ -0,0 +1,10 @@
.flex {
&-row {
display: flex;
flex-direction: row;
}
&-column {
display: flex;
flex-direction: column;
}
}

61
src/styles/global.scss Normal file
View File

@ -0,0 +1,61 @@
// color
$legacy-ie: 10;
$color-primary: #20a0ff;
$color-success: #13ce66;
$color-warning: #f7ba2a;
$color-danger: #ff4949;
$color-info: #50bfff;
$color-secondary: #2e90fe;
$color-white: #ffffff;
$color-black: #1f2d3d;
$color-black-light: #324057;
$color-black-lighter: #475669;
$color-blue-light: #5da9ff;
$color-blue-lighter: #5da9ff;
$color-black: #000000;
$color-silver: #8492a6;
$color-silver-light: #99a9bf;
$color-silver-lighter: #c0ccda;
$color-gray: #d3dce6;
$color-gray-light: #e5e9f2;
$color-gray-lighter: #eff2f7;
$color-333: #333333;
$color-666: #666666;
$color-999: #999999;
$color-border-gray: #d1dbe5;
$color-input-border: #dcdfe6;
$color-border: $color-border-gray;
$color-types: (
primary: (
$color-primary,
#4db3ff,
#1d90e6,
),
info: (
$color-info,
#73ccff,
#48ace6,
),
success: (
$color-success,
#42d885,
#11b95c,
),
warning: (
$color-warning,
#f9c855,
#dea726,
),
danger: (
$color-danger,
#ff6d6d,
#e64242,
),
gray: (
$color-999,
#999999,
#9d9d9d,
),
);
@import 'utils/utils';

17
src/styles/style.scss Normal file
View File

@ -0,0 +1,17 @@
@import 'common/base';
@import 'common/define';
#app {
position: relative;
width: 100%;
height: 100%;
font-family: Avenir, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fff;
}
// .el-dialog__body {
// overflow: hidden auto;
// height: auto;
// max-height: calc(100vh - 130px);
// }

View File

@ -0,0 +1,31 @@
/// Block Element
/// @access public
/// @param {String} $element - Element's name
@mixin element($element) {
&__#{$element} {
@content;
}
}
/// @alias element
@mixin e($element) {
@include element($element) {
@content;
}
}
/// Block Modifier
/// @access public
/// @param {String} $modifier - Modifier's name
@mixin modifier($modifier) {
&_#{$modifier} {
@content;
}
}
/// @alias modifier
@mixin m($modifier) {
@include modifier($modifier) {
@content;
}
}

View File

@ -0,0 +1,30 @@
@mixin ellipsis($lines: 1, $line-height: 0) {
overflow: hidden;
@if $lines == 1 {
@if $legacy-ie <= 8 {
word-wrap: normal; // for ie
}
text-overflow: ellipsis;
white-space: nowrap;
} @else {
display: flexbox;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
@if value-of($line-height) == 0 {
@error 'line-height is required when clamp muti lines';
}
@if unitless($line-height) or unit($line-height) == '%' {
$line-height: value-of($line-height) * 1em;
}
max-height: $line-height * $lines;
// &:after {
// content: " ...";
// }
}
}

View File

@ -0,0 +1,4 @@
@mixin scrollable() {
overflow: hidden auto;
-webkit-overflow-scrolling: touch;
}

View File

@ -0,0 +1,32 @@
/// Mixin to customize scrollbars
/// Beware, this does not work in all browsers
/// @author Hugo Giraudel
/// @param {Length} $size - Horizontal scrollbar's height and vertical scrollbar's width
/// @param {Color} $foreground-color - Scrollbar's color
/// @param {Color} $background-color [mix($foreground-color, white, 50%)] - Scrollbar's color
/// @example scss - Scrollbar styling
/// @include scrollbars(.5em, slategray);
@mixin scrollbars($size, $foreground-color, $background-color: mix($foreground-color, white, 50%)) {
// For Google Chrome
::-webkit-scrollbar {
width: $size;
height: $size;
}
::-webkit-scrollbar-thumb {
background: $foreground-color;
}
::-webkit-scrollbar-track {
background: $background-color;
}
// For Internet Explorer
body {
scrollbar-face-color: $foreground-color;
scrollbar-track-color: $background-color;
}
}
/// alias
@mixin scrollbar($size, $foreground-color, $background-color: mix($foreground-color, white, 50%)) {
@include scrollbars($size, $foreground-color, $background-color: mix($foreground-color, white, 50%));
}

View File

@ -0,0 +1,11 @@
@function value-of($value) {
@if type-of($value) == 'number' and not unitless($value) {
@return $value / ($value * 0 + 1);
}
@return $value;
}
// alias
@function strip-unit($value) {
@return value-of($value);
}

View File

@ -0,0 +1,21 @@
@import 'bem';
@import 'ellipsis';
@import 'scrollable';
@import 'scrollbar';
@import 'value-of';
@mixin flex-row() {
display: flex;
flex-direction: row;
}
@mixin flex-column() {
display: flex;
flex-direction: column;
}
@mixin icon-space() {
display: inline-block;
content: '';
background-size: 100%;
}

123
src/utils/axios.js Normal file
View File

@ -0,0 +1,123 @@
/*
* @Descripttion:
* @Author: zenghua.wang
* @Date: 2022-02-23 21:12:37
* @LastEditors: zenghua.wang
* @LastEditTime: 2025-02-18 09:47:41
*/
import axios from 'axios';
import { ElNotification } from 'element-plus';
import router from '@/router';
import { isEmpty } from '@/utils';
import { useUserStore } from '@/store/modules/user';
const { VITE_APP_BASE_API, VITE_APP_UPLOAD_API, VITE_APP_DICDATA_API } = import.meta.env;
/**
* 创建axios实例
*/
const publicAxios = axios.create({
baseURL: VITE_APP_BASE_API, // API请求的默认前缀
timeout: 30000,
});
/**
* 异常拦截处理器
* @param error
* @returns
*/
const errorHandler = async (error) => {
const { response } = error;
const UserStore = useUserStore();
if (response && response.status) {
switch (response.status) {
case 401:
await UserStore.logout();
router.push('/login');
break;
default:
break;
}
}
return Promise.reject(error?.response?.data);
};
/**
* 请求拦截器
*/
publicAxios.interceptors.request.use(async (config) => {
const UserStore = useUserStore();
switch (config.apisType) {
case 'upload': {
config.baseURL = VITE_APP_UPLOAD_API;
config.headers['Content-Type'] = config.uploadType;
break;
}
case 'dicData': {
config.baseURL = VITE_APP_DICDATA_API;
break;
}
default: {
config.baseURL = VITE_APP_BASE_API;
}
}
if (UserStore.hasToken()) {
config.headers['authorization'] = config.headers['authorization'] ?? UserStore.token;
config.headers['cache-control'] = 'no-cache';
config.headers.Pragma = 'no-cache';
}
if (config.method === 'POST' || config.method === 'DELETE') {
config.headers.Accept = 'application/json';
}
return config;
}, errorHandler);
/**
* 返回结果处理
* @param res
* @returns
*/
const formatResult = async (res) => {
const code = res.data.code || res.status;
// console.info('formatResult', code)
const UserStore = useUserStore();
switch (code) {
case 200:
case 0:
// code === 0 或 200 代表没有错误
return res.data || res.data.data || res;
case 500:
case 1:
// code === 1 或 500 代表存在错误
ElNotification.error(res.data.msg);
break;
case 401:
// code === 401 代表登录过期
await UserStore.logout();
router.push('/login');
break;
default:
ElNotification.error(res.data.msg);
break;
}
};
/**
* 响应拦截器
*/
publicAxios.interceptors.response.use((response) => {
const { config } = response;
// console.info('响应拦截器', response);
if (config?.responseType) {
return response;
}
const token = response?.headers['authorization'];
if (!isEmpty(token)) {
const UserStore = useUserStore();
UserStore.setToken(token);
}
const result = formatResult(response);
if (result) {
return result;
}
throw new Error(response.data.msg);
}, errorHandler);
export default publicAxios;

483
src/utils/index.js Normal file
View File

@ -0,0 +1,483 @@
/*
* @Descripttion:
* @Author: zenghua.wang
* @Date: 2022-02-23 21:12:37
* @LastEditors: zenghua.wang
* @LastEditTime: 2025-02-11 17:18:36
*/
import lodash from 'lodash';
import dayjs from 'dayjs';
import { Base64 } from 'js-base64';
/**
* @Title 防抖指在一定时间内多次触发同一个事件只执行最后一次操作
* @param {*} fn
* @param {*} delay
* @returns
*/
export function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
/**
* @Title 节流指在一定时间内多次触发同一个事件只执行第一次操作
* @param {*} fn
* @param {*} delay
* @returns
*/
export function throttle(fn, delay) {
let timer = null;
return function (...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
}
};
}
/**
* @Title 判断是否 empty,返回ture
* @param {*} val:null 'null' undefined 'undefined' 0 '0' "" 返回true
* @returns
*/
export const isEmpty = (val) => {
if (val && parseInt(val) === 0) return false;
if (typeof val === 'undefined' || val === 'null' || val == null || val === 'undefined' || val === undefined || val === '') {
return true;
} else if (typeof val === 'object' && Object.keys(val).length === 0) {
return true;
} else if (val instanceof Array && val.length === 0) {
return true;
} else {
return false;
}
};
/**
* @Title 深度拷贝对象
* @param {*} obj
* @returns
*/
export const deepClone = (obj = {}) => {
return lodash.cloneDeep(obj);
};
/**
* @Title 将number转换为px
* @param {*} val
* @returns
*/
export const setPx = (val) => {
if (isEmpty(val)) return '';
val = val + '';
if (val.indexOf('%') === -1) {
val = val + 'px';
}
return val;
};
/**
* @Tilte 设置属性默认值
* @param {*} options
* @param {*} prop
* @param {*} defaultVal
* @returns
*/
export const setDefaultOption = (options, prop, defaultVal) => {
return options[prop] === undefined ? defaultVal : options.prop;
};
/**
* @Title 设置字典值
* @param {*} columns
* @param {*} key
* @param {*} data
* @returns
*/
export const setDicData = (columns, key, data = []) => {
if (isEmpty(data)) return;
const len = columns.length;
for (let i = 0; i < len; i++) {
if (columns[i]?.prop === key) {
columns[i]['dicData'] = data;
break;
}
}
};
/**
* @Title 求字段lable
* @param {*} tree
* @returns
*/
export const setDicLabel = (dicData, value) => {
let label = value;
if (isEmpty(dicData)) return label;
const len = dicData.length;
for (let i = 0; i < len; i++) {
if (dicData[i]?.value === value) {
label = dicData[i].label;
break;
}
}
return label;
};
/**
* @Title 数组交集
* @param {*} arr1
* @param {*} arr2
* @returns
*/
export const intersectionArray = (arr1 = [], arr2 = []) => {
return arr1.filter((item) => arr2.includes(item));
};
/**
* @Title 数组并集
* @param {*} arr1
* @param {*} arr2
* @returns
*/
export const unionArray = (arr1 = [], arr2 = []) => {
return Array.from(new Set([...arr1, ...arr2]));
};
/**
* @Title 数组差集
* @param {*} arr1
* @param {*} arr2
* @returns
*/
export const differenceArray = (arr1 = [], arr2 = []) => {
const s = new Set(arr2);
return arr1.filter((x) => !s.has(x));
};
/**
* @Title 加密
* @param {*} n
* @returns
*/
export const encode = (n, flag = false) => {
if (flag) {
return (
((e) => {
let t = e.length.toString();
for (let n = 10 - t.length; n > 0; n--) t = '0' + t;
return t;
})(n) +
((e) => {
const t = Base64.encode(e).split('');
for (let n = 0; n < Math.floor(e.length / 100 + 1); n++) t.splice(100 * n + 1, 0, 3);
return t.join('');
})(n)
);
}
return n;
};
/**
* @Title 解密
* @param {*} e
* @returns
*/
export const decode = (e, flag = false) => {
if (flag) {
try {
const t = Number(e.substr(0, 10));
const n = e.substr(10).split('');
for (let i = 0, s = 0; s < Math.floor(t / 100) + 1; s++) {
n.splice(100 * s + 1 - i, 1);
i++;
}
const o = Base64.decode(n.join(''));
return o;
} catch (error) {
return e;
}
}
return e;
};
/**
* @Title 图片转base64
* @param {*} file
* @returns
*/
export const imageToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
};
/**
* @Title bufferToBase64
* @param {*} buffer
* @returns
*/
export const bufferToBase64 = (buffer) => {
return 'data:image/jpeg;base64,' + window.btoa(new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), ''));
};
/**
* @Title blob转json
* @param {*} file
* @returns
*/
export const blobToJSON = (blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsText(blob, 'utf-8');
reader.onload = () => {
const res = !isEmpty(reader.result) ? JSON.parse(reader.result) : reader.result;
resolve(res);
};
reader.onerror = reject;
});
};
/**
* @Title 将array转化为树
* @param tree
* @returns
*/
export const getTree = (tree = []) => {
tree.forEach((item) => {
delete item.children;
});
const map = {};
tree.forEach((item) => {
map[item.id] = item;
});
const arr = [];
tree.forEach((item) => {
const parent = map[item.parentId];
if (parent) {
(parent.children || (parent.children = [])).push(item);
} else {
arr.push(item);
}
});
return arr;
};
/**
* @Title 获取路由中的参数
* @param name
* @returns
*/
export const getUrlQuery = (name) => {
const url = window.location.href;
const hash = url.substring(url.indexOf('#') + 1);
const searchIndex = hash.indexOf('?');
const search = searchIndex !== -1 ? hash.substring(searchIndex + 1) : '';
const usp = new URLSearchParams(search);
return usp.get(name);
};
/**
* @Title 将Object参数转换为字符串
* @param {*} json
* @returns
*/
export const obj2Param = (json) => {
if (!json) return '';
return Object.keys(json)
.map((key) => {
if (isEmpty(json[key])) return '';
return encodeURIComponent(key) + '=' + encodeURIComponent(json[key]);
})
.join('&');
};
/**
* @Title 获取静态资源文件
* @param {*} url
* @returns
*/
export const getAssetsFile = (url) => {
return new URL(`../assets/${url}`, import.meta.url);
};
/**
* @Title 替换图片url字段值
* @param {*} url
* @returns
*/
export const setUploadField = (url) => {
// if (isEmpty(url) || url.includes('http')) return null;
return isEmpty(url) ? null : url;
};
/**
* @Title: 下载文件
* @param {void} url:
* @param {void} fileName:
* @param {void} fileType:
* @return {void}
*/
export const downloadFile = async (url, fileName, fileType) => {
let blob = null;
try {
switch (fileType) {
case 'image': {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = url;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
blob = await new Promise((resolve) => {
canvas.toBlob(resolve, 'image/jpeg');
});
break;
}
case 'blob':
case 'arraybuffer': {
blob = new Blob([url]);
break;
}
}
if ('download' in document.createElement('a')) {
const elink = document.createElement('a');
elink.download = fileName;
elink.style.display = 'none';
elink.href = blob ? URL.createObjectURL(blob) : url;
document.body.appendChild(elink);
elink.click();
blob && URL.revokeObjectURL(elink.href);
document.body.removeChild(elink);
} else {
navigator.msSaveBlob(blob, fileName);
}
} catch (error) {
console.error('下载出错:', error);
}
};
/**
* @Title 模拟休眠
* @param {*} duration
* @returns
*/
export const sleep = (duration = 0) =>
new Promise((resolve) => {
setTimeout(resolve, duration);
});
/**
* @Title 创建id
* @param {*} prefix
* @returns
*/
export const createId = (prefix) => {
const val = Date.now() + Math.ceil(Math.random() * 99999);
return isEmpty(prefix) ? val : prefix + '-' + val;
};
/**
* @Title 生成数据
* @param {*} duration
* @returns
*/
export const mockData = (item = {}, len = 1) => {
const list = [];
for (let i = 0; i < len; i++) {
let temp = { ...item, id: createId() };
list.push(temp);
}
return list;
};
/**
* @Title 日期格式化
* @param {*} date
* @param {*} format
* @returns
*/
export const dateFormat = (datetime, formater = 'YYYY-MM-DD hh:mm:ss') => {
if (datetime instanceof Date || datetime) {
return dayjs(datetime).format(formater);
} else {
return null;
}
};
/**
* @Title 字符串转日期
* @param {*} str
* @returns
*/
export const toDate = (str) => {
return !isEmpty(str) ? dayjs(str) : dayjs();
};
/**
* @Title 字符串转日期
* @param {*} str
* @returns
*/
export const getDate = (num, type, formater = 'YYYY-MM-DD', start = true) => {
const date = dayjs().subtract(num, type);
return start ? date.startOf(type).format(formater) : date.endOf(type).format(formater);
};
/**
* @Title: 获取时间差
* @param start
* @param end
* @param type
* @returns
*/
export const getDiffTime = (start, end, type) => {
const startTime = dayjs(start);
const endTime = dayjs(end);
const duration = endTime.diff(startTime);
let diff = 0;
switch (type) {
case 'DD': {
diff = duration / (1000 * 60 * 60 * 24);
break;
}
case 'HH': {
diff = duration / (1000 * 60 * 60);
break;
}
case 'mm': {
diff = duration / (1000 * 60);
break;
}
}
return Math.round(diff);
};
/**
* @Title: 开始日期
* @param last
* @param type
* @param formater
* @returns
*/
export const startDate = (num, type = 'month', formater = 'YYYY-MM-DD HH:mm:ss') => {
if (num === 'now') return dayjs().format(formater);
if (typeof num === 'string') return dayjs(num).startOf(type).format(formater);
return num === 0 ? dayjs().startOf(type).format(formater) : dayjs().subtract(num, type).startOf(type).format(formater);
};
/**
* @Title: 结束日期
* @param num
* @param type
* @param formater
* @returns
*/
export const endDate = (num = 0, type = 'month', formater = 'YYYY-MM-DD HH:mm:ss') => {
if (num === 'now') return dayjs().format(formater);
if (typeof num === 'string') return dayjs(num).endOf(type).format(formater);
return num === 0 ? dayjs().endOf(type).format(formater) : dayjs().subtract(num, type).endOf(type).format(formater);
};
/**
* @Title: 生成随机数
* @param len
* @returns
*/
export const randomNumber = (len) => {
let randomlen = len ? len : 10;
const chars = '0123456789';
let result = '';
for (let i = 0; i < randomlen; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};

56
src/utils/permission.js Normal file
View File

@ -0,0 +1,56 @@
/**
* @Description: 路由权限
* @Author: zenghua.wang
* @Date: 2022-01-26 22:04:31
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-02-26 13:54:43
*/
import { qiankunWindow } from 'vite-plugin-qiankun/dist/helper';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import router from '@/router';
import { useUserStore } from '@/store/modules/user';
import { usePermissionStore } from '@/store/modules/permission';
NProgress.configure({ showSpinner: false });
const { VITE_APP_TITLE } = import.meta.env;
const whiteList = [];
router.beforeEach(async (to, from, next) => {
NProgress.start();
if (typeof to.meta.title === 'string') {
document.title = `${VITE_APP_TITLE} | ` + to.meta.title;
}
const userStore = useUserStore();
const hasToken = true; //userStore.hasToken();
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' });
} else {
try {
const PermissionStore = usePermissionStore();
if (!PermissionStore.routes.length) {
const accessRoutes = await PermissionStore.generateRoutes(userStore.roles);
accessRoutes.forEach((item) => router.addRoute(item));
return next({ ...to, replace: true });
} else {
next();
}
} catch (error) {
next(`/login?redirect=${to.path}`);
}
}
} else {
NProgress.done();
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
next(`/login?redirect=${to.path}`);
}
}
});
router.afterEach(() => {
NProgress.done();
});

111
src/utils/router.js Normal file
View File

@ -0,0 +1,111 @@
/**
* @Description: 路由方法
* @Author: zenghua.wang
* @Date: 2022-01-26 21:55:58
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-04-13 21:51:35
*/
import path from 'path-browserify';
import Layout from '@/layouts/index.vue';
import Views from '@/layouts/Views.vue';
import { isEmpty } from './index';
const modules = import.meta.glob('../views/**/**.vue');
/**
* 创建路由菜单
* @param {*} menus
*/
export function createAsyncRoutes(menus = [], isLayout = true) {
if (isEmpty(menus)) return menus;
const res = [];
menus.forEach((menu) => {
const tmp = {
id: menu.id,
parentId: menu.parentId,
path: menu.path,
component: isEmpty(menu.component)
? isLayout
? Layout
: Views
: modules[/* @vite-ignore */ `../views/${menu.component.replace('/views/', '')}`],
redirect: menu.redirect,
name: menu.name,
meta: {
title: menu.title,
icon: menu?.icon || 'icon-demo',
keepAlive: menu.keepAlive,
},
children: menu.children,
};
if (tmp.children) {
tmp.children = createAsyncRoutes(tmp.children, false);
}
res.push(tmp);
});
return res;
}
/**
* 通过递归过滤异步路由表
* @param routes asyncRoutes
* @param roles
*/
export function filterAsyncRoutes(routes, roles) {
const res = [];
routes.forEach((route) => {
const tmp = { ...route };
if (hasPermission(roles, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
res.push(tmp);
}
});
return res;
}
/**
* 使用 meta.role 来确定当前用户是否具有权限
* @param roles
* @param route
*/
export function hasPermission(roles, route) {
if (route.meta && route.meta.roles) {
return roles.some((role) => route.meta.roles.includes(role));
} else {
return false;
}
}
/**
* 使用递归过滤需要缓存的路由
* @param {*} routers
* @returns
*/
export function filterKeepAlive(routers) {
const cacheRouter = [];
const loop = (routers) => {
routers.forEach((item) => {
if (item.meta?.keepAlive && item.name) {
cacheRouter.push(item.name);
}
if (item.children && item.children.length) {
loop(item.children);
}
});
};
loop(routers);
return cacheRouter;
}
/**
*
* @param {*} routers
* @param {*} pathUrl
*/
export function handleRoutes(routers, pathUrl = '') {
routers.forEach((item) => {
item.path = path.resolve(pathUrl, item.path);
});
}

184
src/utils/validate.js Normal file
View File

@ -0,0 +1,184 @@
/**
* @Description: 验证
* @Author: zenghua.wang
* @Date: 2022-01-25 21:08:52
* @LastEditors: zenghua.wang
* @LastEditTime: 2024-01-26 22:22:58
*/
/**
* @param {string} path
* @returns {Boolean}
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path);
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validUsername(str) {
const valid_map = ['admin', 'editor'];
return valid_map.indexOf(str.trim()) >= 0;
}
/**
* @param {string} url
* @returns {Boolean}
*/
export function validURL(url) {
const reg =
/^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/;
return reg.test(url);
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validLowerCase(str) {
const reg = /^[a-z]+$/;
return reg.test(str);
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validUpperCase(str) {
const reg = /^[A-Z]+$/;
return reg.test(str);
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function validAlphabets(str) {
const reg = /^[A-Za-z]+$/;
return reg.test(str);
}
/**
* @param {string} email
* @returns {Boolean}
*/
export function validEmail(email) {
const reg =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return reg.test(email);
}
/**
* @param {string} str
* @returns {Boolean}
*/
export function isString(str) {
if (typeof str === 'string' || str instanceof String) {
return true;
}
return false;
}
/**
* @param {Array} arg
* @returns {Boolean}
*/
export function isArray(arg) {
if (typeof Array.isArray === 'undefined') {
return Object.prototype.toString.call(arg) === '[object Array]';
}
return Array.isArray(arg);
}
/**
* 手机号码
* @param val 当前值字符串
* @returns 返回 true: 手机号码正确
*/
export function verifyPhone(val) {
// false: 手机号码不正确
if (!/^((12[0-9])|(13[0-9])|(14[5|7])|(15([0-3]|[5-9]))|(18[0|1,5-9]))\d{8}$/.test(val)) return false;
// true: 手机号码正确
else return true;
}
/**
* 匹配文字变色搜索时
* @param val 当前值字符串
* @param text 要处理的字符串值
* @param color 搜索到时字体高亮颜色
* @returns 返回处理后的字符串
*/
export function verifyTextColor(val, text = '', color = 'red') {
// 返回内容,添加颜色
const v = text.replace(new RegExp(val, 'gi'), `<span style='color: ${color}'>${val}</span>`);
// 返回结果
return v;
}
/**
* 身份证号, 支持1/2(15/18位数字)
* @param val 当前值字符串
* @returns 返回 true: 身份证正确
*/
export function verifyIdCard(val) {
const regx = /(^\d{8}(0\d|10|11|12)([0-2]\d|30|31)\d{3}$)|(^\d{6}(18|19|20)\d{2}(0\d|10|11|12)([0-2]\d|30|31)\d{3}(\d|X|x)$)/;
return regx.test(val);
}
/**
* 网址
* @param val 当前值字符串
* @returns 返回 true: 网址正确
*/
export function verifyWebsite(val) {
const regx = /^((https?|ftp):\/\/)?([\da-z.-]+)\.([a-z.]{2,6})(\/\w\.-]*)*\/?/;
return regx.test(val);
}
/**
* 是否html标签
* @param val 当前值字符串
* @returns 返回 true: 是否html标签
*/
export function verifyHtml(val) {
const regx = /<(.*)>.*<\/\1>|<(.*) \/>/;
return regx.test(val);
}
/**
* 日期
* @param val 当前值字符串
* @returns 返回 true: 是否日期
*/
export function verifyDate(val) {
const regx =
/^(?:(?!0000)[0-9]{4}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$/;
return regx.test(val);
}
/**
* 邮箱
* @param val 当前值字符串
* @returns 返回 true: 邮箱是否正确
*/
export function verifyEmail(val) {
const regx = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return regx.test(val);
}
/**
* 验证校验器函数封装
* @param verifyPhone 验证函数
* @param message 提示
*/
export function validatorMethod(verifyPhone, message) {
return (rule, value, callback) => {
if (!verifyPhone(value)) {
callback(new Error(message));
} else {
callback();
}
};
}

64
src/views/demo/rank.vue Normal file
View File

@ -0,0 +1,64 @@
<template>
<div class="demo">
<h2 class="demo-title">滚动排名列表</h2>
<custom-rank-List :chart-config="options" />
</div>
</template>
<script setup>
import { ref } from 'vue';
const options = ref({
attr: { w: 200, h: 200 },
option: {
//
dataset: [
{ name: '荣成', value: 26700 },
{ name: '河南', value: 20700 },
{ name: '河北', value: 18700 },
{ name: '徐州', value: 17800 },
{ name: '漯河', value: 16756 },
{ name: '三门峡', value: 12343 },
{ name: '郑州', value: 9822 },
{ name: '周口', value: 8912 },
{ name: '濮阳', value: 6834 },
{ name: '信阳', value: 5875 },
{ name: '新乡', value: 3832 },
{ name: '大同', value: 1811 },
],
//
rowNum: 5,
//
waitTime: 2,
//
unit: '',
//
sort: true,
color: '#1370fb',
textColor: '#CDD2F8FF',
borderColor: '#1370fb80',
carousel: 'single',
//
indexFontSize: 12,
//
leftFontSize: 12,
//
rightFontSize: 12,
//
valueFormatter: (item) => {
return item.value;
},
},
});
</script>
<style lang="scss" scoped>
.demo {
width: 500px;
height: 500px;
margin: 30px auto 0;
background-color: #fff;
&-title {
margin-bottom: 20px;
}
}
</style>

View File

@ -0,0 +1,50 @@
<template>
<div class="demo">
<h2 class="demo-title">轮播列表</h2>
<custom-scroll-board :chart-config="boardOption" />
</div>
</template>
<script setup>
import { ref } from 'vue';
const boardOption = ref({
attr: { w: 200, h: 200 },
option: {
header: ['列1', '列2', '列3'],
dataset: [
['行1列1', '行1列2', '行1列3'],
['行2列1', '行2列2', '行2列3'],
['行3列1', '行3列2', '行3列3'],
['行4列1', '行4列2', '行4列3'],
['行5列1', '行5列2', '行5列3'],
['行6列1', '行6列2', '行6列3'],
['行7列1', '行7列2', '行7列3'],
['行8列1', '行8列2', '行8列3'],
['行9列1', '行9列2', '行9列3'],
['行10列1', '行10列2', '行10列3'],
],
index: true,
columnWidth: [30, 100, 100],
align: ['center', 'right', 'right', 'right'],
rowNum: 5,
waitTime: 2,
headerHeight: 40,
carousel: 'single',
headerBGC: '#00BAFF',
oddRowBGC: '#003B51',
evenRowBGC: '#0A2732',
},
});
</script>
<style lang="scss" scoped>
.demo {
width: 500px;
height: 500px;
margin: 30px auto 0;
background-color: #fff;
&-title {
margin-bottom: 20px;
}
}
</style>

3
src/views/home/index.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div>开发中</div>
</template>

110
vite.config.js Normal file
View File

@ -0,0 +1,110 @@
/*
* @Descripttion:
* @Author: zenghua.wang
* @Date: 2022-09-18 21:24:29
* @LastEditors: zenghua.wang
* @LastEditTime: 2025-04-18 15:41:11
*/
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
// import qiankun from 'vite-plugin-qiankun';
import eslintPlugin from 'vite-plugin-eslint';
import vueSetupExtend from 'vite-plugin-vue-setup-extend';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import compression from 'vite-plugin-compression';
import { viteMockServe } from 'vite-plugin-mock';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import postcssImport from 'postcss-import';
import autoprefixer from 'autoprefixer';
import { resolve } from 'path';
const useDevMode = true;
export default defineConfig(({ command, mode }) => {
const { VITE_PORT, VITE_APP_NAME, VITE_APP_BASE_API, VITE_APP_BASE_URL } = loadEnv(mode, process.cwd());
const config = {
base: '/',
build: {
target: 'ESNext',
outDir: 'dist',
minify: 'terser',
},
server: {
host: '0.0.0.0',
port: VITE_PORT,
open: true,
https: false,
headers: {
'Access-Control-Allow-Origin': '*',
},
proxy: {
[VITE_APP_BASE_API]: {
target: VITE_APP_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/apis/, ''),
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
extensions: ['.js', '.vue', '.json', '.ts'],
},
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "@/styles/global.scss";',
api: 'modern-compiler',
},
},
postcss: {
plugins: [
postcssImport,
autoprefixer({
overrideBrowserslist: ['> 1%', 'last 2 versions'],
}),
],
},
},
plugins: [
vue(),
// qiankun(VITE_APP_NAME, { useDevMode }),
vueSetupExtend(),
eslintPlugin({
include: ['src/**/*.ts', 'src/**/*.vue', 'src/*.ts', 'src/*.vue'],
}),
Components({
dirs: ['src/components'],
extensions: ['vue', 'js', 'jsx', 'ts', 'tsx'],
resolvers: [],
}),
compression(),
AutoImport({
include: [/\.[tj]s?$/, /\.vue$/],
imports: ['vue', 'vue-router'],
}),
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), 'src/assets/svgs')],
symbolId: 'icon-[name]',
}),
viteMockServe({
mockPath: 'src/mock',
watchFiles: true,
localEnabled: command === 'dev',
prodEnabled: false,
}),
],
};
if (mode === 'production') {
config.build.terserOptions = {
compress: {
drop_console: true,
drop_debugger: true,
},
};
}
return config;
});

6807
yarn.lock Normal file

File diff suppressed because it is too large Load Diff