Merge remote-tracking branch 'origin/dev' into feature/springdoc

This commit is contained in:
xingyu
2022-12-21 22:46:52 +08:00
68 changed files with 3812 additions and 2122 deletions

View File

@ -12,3 +12,6 @@ VITE_APP_TENANT_ENABLE=true
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=true
# 路由在只有一个子集的时候是否显示父级
VITE_ROUTE_ALWAYSSHOW_ENABLE=true

View File

@ -1,21 +1,20 @@
<h1>🌈 yudao-ui-admin-vue3</h1>
# 🌈 yudao-ui-admin-vue3 #
<p align="center">
<img src="https://img.shields.io/badge/-Vue3.2-34495e?logo=vue.j" />
<img src="https://img.shields.io/badge/-Vite3-646cff?logo=vite&logoColor=white" />
<img src="https://img.shields.io/badge/-TypeScript4.9-blue?logo=typescript&logoColor=white" />
<img src="https://img.shields.io/badge/-Pinia2-yellow?logo=picpay&logoColor=white" />
<img src="https://img.shields.io/badge/-ESLint-4b32c3?logo=eslint&logoColor=white" />
<img src="https://img.shields.io/badge/-pnpm7-F69220?logo=pnpm&logoColor=white" />
<img src="https://img.shields.io/badge/-Axios-008fc7?logo=axios.js&logoColor=white" />
<p style="text-align: center">
<img src="https://img.shields.io/badge/-Vue3.2-34495e?logo=vue.j" alt="vue" />
<img src="https://img.shields.io/badge/-Vite4-646cff?logo=vite&logoColor=white" alt="vite" />
<img src="https://img.shields.io/badge/-TypeScript4.9-blue?logo=typescript&logoColor=white" alt="typescript" />
<img src="https://img.shields.io/badge/-Pinia2-yellow?logo=picpay&logoColor=white" alt="Pinia2" />
<img src="https://img.shields.io/badge/-ESLint-4b32c3?logo=eslint&logoColor=white" alt="eslint" />
<img src="https://img.shields.io/badge/-pnpm7-F69220?logo=pnpm&logoColor=white" alt="pnpm" />
<img src="https://img.shields.io/badge/-Prettier-ef9421?logo=Prettier&logoColor=white" alt="Prettier">
<img src="https://img.shields.io/badge/-Sass-1D365D?logo=Sass&logoColor=white" alt="Sass">
<img src="https://img.shields.io/badge/-Wind%20CSS-06B6D4?logo=Tailwind%20CSS&logoColor=white" alt="Taiwind">
<img src="https://img.shields.io/badge/-Wind%20CSS-06B6D4?logo=Tailwind%20CSS&logoColor=white" alt="WindCSS">
</p>
## 介绍
- 基于 vue3.2+ TypeScript Element Plus 2.2.0+ Vite3 Pinia Vxe-table , Windicss 等开发的后台管理系统
- 基于 vue3.2+ TypeScript Element Plus 2.2.0+ Vite4 Pinia Vxe-table , Windicss 等开发的后台管理系统
## 注意事项
@ -30,12 +29,12 @@
| 框架 | 说明 | 版本 |
| --- | --- | --- |
| [Vue](https://staging-cn.vuejs.org/) | vue 框架 | 3.2.45 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 3.2.3 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.2.23 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 4.0.2 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.2.27 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 的超集 | 4.9.4 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库 替代 vuex5 | 2.0.26 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库 替代 vuex5 | 2.0.28 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 9.6.0 |
| [vxe-table](https://vxetable.cn/) | vue 最强表单 | 4.3.6 |
| [vxe-table](https://vxetable.cn/) | vue 最强表单 | 4.3.7 |
| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 9.2.2 |
| [vue-router](https://router.vuejs.org/) | vue 路由 | 4.1.6 |
| [windicss](https://cn.windicss.org/) | 下一代工具优先的 CSS 框架 | 3.5.6 |

View File

@ -1,7 +1,6 @@
import { resolve } from 'path'
import Vue from '@vitejs/plugin-vue'
import VueJsx from '@vitejs/plugin-vue-jsx'
import VueI18n from '@intlify/vite-plugin-vue-i18n'
import WindiCSS from 'vite-plugin-windicss'
import progress from 'vite-plugin-progress'
import EslintPlugin from 'vite-plugin-eslint'
@ -9,6 +8,7 @@ import PurgeIcons from 'vite-plugin-purge-icons'
import { ViteEjsPlugin } from 'vite-plugin-ejs'
import viteCompression from 'vite-plugin-compression'
import vueSetupExtend from 'vite-plugin-vue-setup-extend'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import {
createStyleImportPlugin,
@ -51,7 +51,7 @@ export function createVitePlugins(VITE_APP_TITLE: string) {
cache: false,
include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'] // 检查的文件
}),
VueI18n({
VueI18nPlugin({
runtimeOnly: true,
compositionOnly: true,
include: [resolve(__dirname, 'src/locales/**')]

View File

@ -1,7 +1,7 @@
{
"name": "yudao-ui-admin-vue3",
"version": "1.6.5.1874",
"description": "基于vue3、vite3、element-plus、typesScript",
"version": "1.6.5.1878",
"description": "基于vue3、vite4、element-plus、typesScript",
"author": "xingyu",
"private": false,
"scripts": {
@ -25,67 +25,67 @@
},
"dependencies": {
"@iconify/iconify": "^3.0.1",
"@vueuse/core": "^9.6.0",
"@vueuse/core": "^9.8.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.10",
"@zxcvbn-ts/core": "^2.1.0",
"animate.css": "^4.1.1",
"axios": "^1.2.1",
"cropperjs": "^1.5.13",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.7",
"echarts": "^5.4.0",
"echarts": "^5.4.1",
"echarts-wordcloud": "^2.1.0",
"element-plus": "2.2.26",
"element-plus": "2.2.27",
"intro.js": "^6.0.0",
"jsencrypt": "^3.3.1",
"lodash-es": "^4.17.21",
"mitt": "^3.0.0",
"nprogress": "^0.2.0",
"pinia": "^2.0.27",
"pinia": "^2.0.28",
"qrcode": "^1.5.1",
"qs": "^6.11.0",
"url": "^0.11.0",
"vue": "3.2.45",
"vue-cropper": "^1.0.3",
"vue-i18n": "9.2.2",
"vue-router": "^4.1.6",
"vue-types": "^5.0.1",
"vxe-table": "^4.3.6",
"vxe-table": "^4.3.7",
"web-storage-cache": "^1.1.1",
"xe-utils": "^3.5.7"
},
"devDependencies": {
"@commitlint/cli": "^17.3.0",
"@commitlint/config-conventional": "^17.3.0",
"@iconify/json": "^2.1.149",
"@intlify/vite-plugin-vue-i18n": "^6.0.3",
"@iconify/json": "^2.1.155",
"@intlify/unplugin-vue-i18n": "^0.8.1",
"@purge-icons/generated": "^0.9.0",
"@types/intro.js": "^5.1.0",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18.11.11",
"@types/node": "^18.11.17",
"@types/nprogress": "^0.2.0",
"@types/qrcode": "^1.5.0",
"@types/qs": "^6.9.7",
"@typescript-eslint/eslint-plugin": "^5.46.0",
"@typescript-eslint/parser": "^5.46.0",
"@vitejs/plugin-legacy": "^2.3.1",
"@vitejs/plugin-vue": "^3.2.0",
"@vitejs/plugin-vue-jsx": "^2.1.1",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"@vitejs/plugin-legacy": "^3.0.1",
"@vitejs/plugin-vue": "^4.0.0",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"autoprefixer": "^10.4.13",
"consola": "^2.15.3",
"eslint": "^8.29.0",
"eslint": "^8.30.0",
"eslint-config-prettier": "^8.5.0",
"eslint-define-config": "^1.12.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.8.0",
"lint-staged": "^13.1.0",
"postcss": "^8.4.19",
"postcss": "^8.4.20",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.1",
"rimraf": "^3.0.2",
"rollup": "^3.7.0",
"sass": "^1.56.1",
"rollup": "^3.7.5",
"sass": "^1.57.1",
"stylelint": "^14.16.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier": "^9.0.4",
@ -94,17 +94,17 @@
"stylelint-order": "^5.0.0",
"terser": "^5.16.1",
"typescript": "4.9.4",
"vite": "3.2.5",
"vite": "4.0.2",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-ejs": "^1.6.4",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-progress": "^0.0.6",
"vite-plugin-purge-icons": "^0.9.1",
"vite-plugin-purge-icons": "^0.9.2",
"vite-plugin-style-import": "2.0.0",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vite-plugin-windicss": "^1.8.8",
"vue-tsc": "^1.0.11",
"vite-plugin-windicss": "^1.8.10",
"vue-tsc": "^1.0.16",
"windicss": "^3.5.6"
},
"engines": {

File diff suppressed because it is too large Load Diff

View File

@ -73,5 +73,5 @@ export const updateUserPwdApi = (oldPassword: string, newPassword: string) => {
// 用户头像上传
export const uploadAvatarApi = (data) => {
return request.put({ url: '/system/user/profile/update-avatar', data })
return request.upload({ url: '/system/user/profile/update-avatar', data: data })
}

View File

@ -2,7 +2,7 @@
import { ElCard } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
import { ref, onMounted, defineEmits } from 'vue'
import { ref, onMounted } from 'vue'
import { Sticky } from '@/components/Sticky'
import { useI18n } from '@/hooks/web/useI18n'
const { t } = useI18n()

View File

@ -0,0 +1,4 @@
import CropperImage from './src/Cropper.vue'
import CropperAvatar from './src/CropperAvatar.vue'
export { CropperImage, CropperAvatar }

View File

@ -0,0 +1,257 @@
<template>
<div>
<Dialog
v-model="dialogVisible"
:title="t('cropper.modalTitle')"
width="800px"
maxHeight="380px"
:canFullscreen="false"
>
<div :class="prefixCls">
<div :class="`${prefixCls}-left`">
<div :class="`${prefixCls}-cropper`">
<CropperImage
v-if="src"
:src="src"
height="300px"
:circled="circled"
@cropend="handleCropend"
@ready="handleReady"
/>
</div>
<div :class="`${prefixCls}-toolbar`">
<el-upload :fileList="[]" accept="image/*" :beforeUpload="handleBeforeUpload">
<el-tooltip :content="t('cropper.selectImage')" placement="bottom">
<XButton preIcon="ant-design:upload-outlined" type="primary" />
</el-tooltip>
</el-upload>
<el-space>
<el-tooltip :content="t('cropper.btn_reset')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:reload-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('reset')"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_rotate_left')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:rotate-left-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('rotate', -45)"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_rotate_right')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:rotate-right-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('rotate', 45)"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_scale_x')" placement="bottom">
<XButton
type="primary"
preIcon="vaadin:arrows-long-h"
size="small"
:disabled="!src"
@click="handlerToolbar('scaleX')"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_scale_y')" placement="bottom">
<XButton
type="primary"
preIcon="vaadin:arrows-long-v"
size="small"
:disabled="!src"
@click="handlerToolbar('scaleY')"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_zoom_in')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:zoom-in-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('zoom', 0.1)"
/>
</el-tooltip>
<el-tooltip :content="t('cropper.btn_zoom_out')" placement="bottom">
<XButton
type="primary"
preIcon="ant-design:zoom-out-outlined"
size="small"
:disabled="!src"
@click="handlerToolbar('zoom', -0.1)"
/>
</el-tooltip>
</el-space>
</div>
</div>
<div :class="`${prefixCls}-right`">
<div :class="`${prefixCls}-preview`">
<img :src="previewSource" v-if="previewSource" :alt="t('cropper.preview')" />
</div>
<template v-if="previewSource">
<div :class="`${prefixCls}-group`">
<el-avatar :src="previewSource" size="large" />
<el-avatar :src="previewSource" :size="48" />
<el-avatar :src="previewSource" :size="64" />
<el-avatar :src="previewSource" :size="80" />
</div>
</template>
</div>
</div>
<template #footer>
<el-button type="primary" @click="handleOk">{{ t('cropper.okText') }}</el-button>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { useDesign } from '@/hooks/web/useDesign'
import { dataURLtoBlob } from '@/utils/filt'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElUpload, ElAvatar, ElTooltip, ElSpace } from 'element-plus'
import { Dialog } from '@/components/Dialog'
import { CropperImage } from '@/components/Cropper'
import type { CropendResult, Cropper } from './types'
import { propTypes } from '@/utils/propTypes'
const props = defineProps({
srcValue: propTypes.string.def(''),
circled: propTypes.bool.def(true)
})
const emit = defineEmits(['uploadSuccess'])
const { t } = useI18n()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-am')
const src = ref(props.srcValue)
const previewSource = ref('')
const cropper = ref<Cropper>()
const dialogVisible = ref(false)
let filename = ''
let scaleX = 1
let scaleY = 1
// Block upload
function handleBeforeUpload(file: File) {
const reader = new FileReader()
reader.readAsDataURL(file)
src.value = ''
previewSource.value = ''
reader.onload = function (e) {
src.value = (e.target?.result as string) ?? ''
filename = file.name
}
return false
}
function handleCropend({ imgBase64 }: CropendResult) {
previewSource.value = imgBase64
}
function handleReady(cropperInstance: Cropper) {
cropper.value = cropperInstance
}
function handlerToolbar(event: string, arg?: number) {
if (event === 'scaleX') {
scaleX = arg = scaleX === -1 ? 1 : -1
}
if (event === 'scaleY') {
scaleY = arg = scaleY === -1 ? 1 : -1
}
cropper?.value?.[event]?.(arg)
}
async function handleOk() {
const blob = dataURLtoBlob(previewSource.value)
emit('uploadSuccess', { source: previewSource.value, data: blob, filename: filename })
}
function openModal() {
dialogVisible.value = true
}
function closeModal() {
dialogVisible.value = false
}
defineExpose({ openModal, closeModal })
</script>
<style lang="scss">
$prefix-cls: #{$namespace}-cropper-am;
.#{$prefix-cls} {
display: flex;
&-left,
&-right {
height: 340px;
}
&-left {
width: 55%;
}
&-right {
width: 45%;
}
&-cropper {
height: 300px;
background: #eee;
background-image: linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,
transparent 75%,
rgb(0 0 0 / 25%) 0
),
linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,
transparent 75%,
rgb(0 0 0 / 25%) 0
);
background-position: 0 0, 12px 12px;
background-size: 24px 24px;
}
&-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
&-preview {
width: 220px;
height: 220px;
margin: 0 auto;
overflow: hidden;
border: 1px solid;
border-radius: 50%;
img {
width: 100%;
height: 100%;
}
}
&-group {
display: flex;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid;
justify-content: space-around;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,190 @@
<template>
<div :class="getClass" :style="getWrapperStyle">
<img
v-show="isReady"
ref="imgElRef"
:src="src"
:alt="alt"
:crossorigin="crossorigin"
:style="getImageStyle"
/>
</div>
</template>
<script setup lang="ts">
import {
computed,
CSSProperties,
onMounted,
onUnmounted,
PropType,
ref,
unref,
useAttrs
} from 'vue'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import { useDesign } from '@/hooks/web/useDesign'
import { useDebounceFn } from '@vueuse/core'
import { propTypes } from '@/utils/propTypes'
type Options = Cropper.Options
const defaultOptions: Options = {
aspectRatio: 1,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
autoCrop: true,
background: true,
highlight: true,
center: true,
responsive: true,
restore: true,
checkCrossOrigin: true,
checkOrientation: true,
scalable: true,
modal: true,
guides: true,
movable: true,
rotatable: true
}
const props = defineProps({
src: propTypes.string.def(''),
alt: propTypes.string.def(''),
circled: propTypes.bool.def(false),
realTimePreview: propTypes.bool.def(true),
height: propTypes.string.def('360px'),
crossorigin: {
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
default: undefined
},
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
options: { type: Object as PropType<Options>, default: () => ({}) }
})
const emit = defineEmits(['cropend', 'ready', 'cropendError'])
const attrs = useAttrs()
const imgElRef = ref<ElRef<HTMLImageElement>>()
const cropper = ref<Nullable<Cropper>>()
const isReady = ref(false)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-image')
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80)
const getImageStyle = computed((): CSSProperties => {
return {
height: props.height,
maxWidth: '100%',
...props.imageStyle
}
})
const getClass = computed(() => {
return [
prefixCls,
attrs.class,
{
[`${prefixCls}--circled`]: props.circled
}
]
})
const getWrapperStyle = computed((): CSSProperties => {
return { height: `${props.height}`.replace(/px/, '') + 'px' }
})
onMounted(init)
onUnmounted(() => {
cropper.value?.destroy()
})
async function init() {
const imgEl = unref(imgElRef)
if (!imgEl) {
return
}
cropper.value = new Cropper(imgEl, {
...defaultOptions,
ready: () => {
isReady.value = true
realTimeCroppered()
emit('ready', cropper.value)
},
crop() {
debounceRealTimeCroppered()
},
zoom() {
debounceRealTimeCroppered()
},
cropmove() {
debounceRealTimeCroppered()
},
...props.options
})
}
// Real-time display preview
function realTimeCroppered() {
props.realTimePreview && croppered()
}
// event: return base64 and width and height information after cropping
function croppered() {
if (!cropper.value) {
return
}
let imgInfo = cropper.value.getData()
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas()
canvas.toBlob((blob) => {
if (!blob) {
return
}
let fileReader: FileReader = new FileReader()
fileReader.readAsDataURL(blob)
fileReader.onloadend = (e) => {
emit('cropend', {
imgBase64: e.target?.result ?? '',
imgInfo
})
}
fileReader.onerror = () => {
emit('cropendError')
}
}, 'image/png')
}
// Get a circular picture canvas
function getRoundedCanvas() {
const sourceCanvas = cropper.value!.getCroppedCanvas()
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
const width = sourceCanvas.width
const height = sourceCanvas.height
canvas.width = width
canvas.height = height
context.imageSmoothingEnabled = true
context.drawImage(sourceCanvas, 0, 0, width, height)
context.globalCompositeOperation = 'destination-in'
context.beginPath()
context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true)
context.fill()
return canvas
}
</script>
<style lang="scss">
$prefix-cls: #{$namespace}-cropper-image;
.#{$prefix-cls} {
&--circled {
.cropper-view-box,
.cropper-face {
border-radius: 50%;
}
}
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<div class="user-info-head" @click="open()">
<img :src="sourceValue" v-if="sourceValue" class="img-circle img-lg" alt="avatar" />
<el-button :class="`${prefixCls}-upload-btn`" @click="open()" v-if="showBtn">
{{ btnText ? btnText : t('cropper.selectImage') }}
</el-button>
<CopperModal
ref="cropperModelRef"
@upload-success="handleUploadSuccess"
:srcValue="sourceValue"
/>
</div>
</template>
<script setup lang="ts">
import { useDesign } from '@/hooks/web/useDesign'
import { useMessage } from '@/hooks/web/useMessage'
import { propTypes } from '@/utils/propTypes'
import { ref, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import CopperModal from './CopperModal.vue'
const props = defineProps({
width: propTypes.string.def('200px'),
value: propTypes.string.def(''),
showBtn: propTypes.bool.def(true),
btnText: propTypes.string.def('')
})
const emit = defineEmits(['update:value', 'change'])
const sourceValue = ref(props.value)
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('cropper-avatar')
const message = useMessage()
const { t } = useI18n()
const cropperModelRef = ref()
watchEffect(() => {
sourceValue.value = props.value
})
watch(
() => sourceValue.value,
(v: string) => {
emit('update:value', v)
}
)
function handleUploadSuccess({ source, data, filename }) {
sourceValue.value = source
emit('change', { source, data, filename })
message.success(t('cropper.uploadSuccess'))
}
function open() {
cropperModelRef.value.openModal()
}
function close() {
cropperModelRef.value.closeModal()
}
defineExpose({
open,
close
})
</script>
<style lang="scss" scoped>
$prefix-cls: #{$namespace}--cropper-avatar;
.#{$prefix-cls} {
display: inline-block;
text-align: center;
&-image-wrapper {
overflow: hidden;
cursor: pointer;
border: 1px solid;
border-radius: 50%;
img {
width: 100%;
}
}
&-image-mask {
opacity: 0%;
position: absolute;
width: inherit;
height: inherit;
border-radius: inherit;
border: inherit;
background: rgb(0 0 0 / 40%);
cursor: pointer;
transition: opacity 0.4s;
::v-deep(svg) {
margin: auto;
}
}
&-image-mask:hover {
opacity: 4000%;
}
&-upload-btn {
margin: 10px auto;
}
}
.user-info-head {
position: relative;
display: inline-block;
}
.img-circle {
border-radius: 50%;
}
.img-lg {
width: 120px;
height: 120px;
}
.user-info-head:hover:after {
content: '+';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
line-height: 110px;
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,8 @@
import type Cropper from 'cropperjs'
export interface CropendResult {
imgBase64: string
imgInfo: Cropper.Data
}
export type { Cropper }

View File

@ -23,6 +23,7 @@ const props = defineProps({
type: Object as PropType<IEditorConfig>,
default: () => undefined
},
readonly: propTypes.bool.def(false),
modelValue: propTypes.string.def('')
})
@ -61,7 +62,7 @@ const editorConfig = computed((): IEditorConfig => {
return Object.assign(
{
placeholder: '请输入内容...',
readOnly: false,
readOnly: props.readonly,
customAlert: (s: string, t: string) => {
switch (t) {
case 'success':

View File

@ -21,7 +21,7 @@ import {
} from 'element-plus'
import { InputPassword } from '@/components/InputPassword'
import { Editor } from '@/components/Editor'
import { UploadImg, UploadFile } from '@/components/UploadFile'
import { UploadImg, UploadImgs, UploadFile } from '@/components/UploadFile'
import { ComponentName } from '@/types/components'
const componentMap: Recordable<Component, ComponentName> = {
@ -48,6 +48,7 @@ const componentMap: Recordable<Component, ComponentName> = {
InputPassword: InputPassword,
Editor: Editor,
UploadImg: UploadImg,
UploadImgs: UploadImgs,
UploadFile: UploadFile
}

View File

@ -15,7 +15,9 @@ const props = defineProps({
// icon color
color: propTypes.string,
// icon size
size: propTypes.number.def(16)
size: propTypes.number.def(16),
// icon svg class
svgClass: propTypes.string.def('')
})
const elRef = ref<ElRef>(null)
@ -34,6 +36,11 @@ const getIconifyStyle = computed(() => {
}
})
const getSvgClass = computed(() => {
const { svgClass } = props
return `iconify ${svgClass}`
})
const updateIcon = async (icon: string) => {
if (unref(isLocal)) return
@ -66,13 +73,13 @@ watch(
</script>
<template>
<ElIcon :class="prefixCls" :size="size" :color="color">
<svg v-if="isLocal" aria-hidden="true">
<ElIcon :class="prefixCls" :color="color" :size="size">
<svg v-if="isLocal" aria-hidden="true" :class="getSvgClass">
<use :xlink:href="symbolId" />
</svg>
<span v-else ref="elRef" :class="$attrs.class" :style="getIconifyStyle">
<span class="iconify" :data-icon="symbolId"></span>
<span :class="getSvgClass" :data-icon="symbolId"></span>
</span>
</ElIcon>
</template>

View File

@ -64,7 +64,7 @@ const initQrcode = async () => {
options.errorCorrectionLevel || getErrorCorrectionLevel(unref(renderText))
const _width: number = await getOriginWidth(unref(renderText), options)
options.scale = props.width === 0 ? undefined : (props.width / _width) * 4
const canvasRef: HTMLCanvasElement = await toCanvas(
const canvasRef: HTMLCanvasElement | any = await toCanvas(
unref(wrapRef) as HTMLCanvasElement,
unref(renderText),
options

View File

@ -1,4 +1,5 @@
import UploadImg from './src/UploadImg.vue'
import UploadImgs from './src/UploadImgs.vue'
import UploadFile from './src/UploadFile.vue'
export { UploadImg, UploadFile }
export { UploadImg, UploadImgs, UploadFile }

View File

@ -32,8 +32,8 @@
</el-upload>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
<script setup lang="ts" name="UploadFile">
import { PropType, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { propTypes } from '@/utils/propTypes'
import { getAccessToken, getTenantId } from '@/utils/auth'
@ -43,7 +43,10 @@ const message = useMessage() // 消息弹窗
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: propTypes.oneOfType([String, Object, Array]),
modelValue: {
type: Array as PropType<UploadUserFile[]>,
required: true
},
title: propTypes.string.def('文件上传'),
updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
@ -57,40 +60,12 @@ const props = defineProps({
const valueRef = ref(props.modelValue)
const uploadRef = ref<UploadInstance>()
const uploadList = ref<UploadUserFile[]>([])
const fileList = ref<UploadUserFile[]>([])
const fileList = ref<UploadUserFile[]>(props.modelValue)
const uploadNumber = ref<number>(0)
const uploadHeaders = ref({
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
})
watch(
() => props.modelValue,
(val) => {
if (val) {
// 首先将值转为数组, 当只穿了一个图片时会报map方法错误
const list = Array.isArray(props.modelValue)
? props.modelValue
: Array.isArray(props.modelValue?.split(','))
? props.modelValue?.split(',')
: Array.of(props.modelValue)
// 然后将数组转为对象数组
fileList.value = list.map((item) => {
if (typeof item === 'string') {
// edit by 芋道源码
item = { name: item, url: item }
}
return item
})
} else {
fileList.value = []
return []
}
},
{
deep: true,
immediate: true
}
)
// 文件上传之前判断
const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
if (fileList.value.length >= props.limit) {

View File

@ -1,176 +1,267 @@
<template>
<div class="component-upload-image">
<div class="upload-box">
<el-upload
ref="uploadRef"
:multiple="props.limit > 1"
name="file"
v-model="valueRef"
list-type="picture-card"
v-model:file-list="fileList"
:show-file-list="true"
:action="updateUrl"
:id="uuid"
:class="['upload', drag ? 'no-border' : '']"
:multiple="false"
:show-file-list="false"
:headers="uploadHeaders"
:limit="props.limit"
:before-upload="beforeUpload"
:on-exceed="handleExceed"
:on-success="handleFileSuccess"
:on-error="excelUploadError"
:on-remove="handleRemove"
:on-preview="handlePictureCardPreview"
:class="{ hide: fileList.length >= props.limit }"
:on-success="uploadSuccess"
:on-error="uploadError"
:drag="drag"
:accept="fileType.join(',')"
>
<Icon icon="ep:upload-filled" />
<template v-if="modelValue">
<img :src="modelValue" class="upload-image" />
<div class="upload-handle" @click.stop>
<div class="handle-icon" @click="editImg">
<Icon icon="ep:edit" />
<span>{{ t('action.edit') }}</span>
</div>
<div class="handle-icon" @click="imgViewVisible = true">
<Icon icon="ep:zoom-in" />
<span>{{ t('action.detail') }}</span>
</div>
<div class="handle-icon" @click="deleteImg">
<Icon icon="ep:delete" />
<span>{{ t('action.del') }}</span>
</div>
</div>
</template>
<template v-else>
<div class="upload-empty">
<slot name="empty">
<Icon icon="ep:plus" />
<!-- <span>请上传图片</span> -->
</slot>
</div>
</template>
</el-upload>
<div class="el-upload__tip">
<slot name="tip"></slot>
</div>
<el-image-viewer
v-if="imgViewVisible"
@close="imgViewVisible = false"
:url-list="[modelValue]"
/>
</div>
<!-- 文件列表 -->
<Dialog v-model="dialogVisible" title="预览" width="800" append-to-body>
<img :src="dialogImageUrl" style="display: block; max-width: 100%; margin: 0 auto" />
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Dialog } from '@/components/Dialog'
<script setup lang="ts" name="UploadImg">
import { ref } from 'vue'
import type { UploadProps } from 'element-plus'
import { ElUpload, ElNotification, ElImageViewer } from 'element-plus'
import { useI18n } from '@/hooks/web/useI18n'
import { useMessage } from '@/hooks/web/useMessage'
import { generateUUID } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import { getAccessToken, getTenantId } from '@/utils/auth'
import { ElUpload, UploadInstance, UploadProps, UploadRawFile, UploadUserFile } from 'element-plus'
type FileTypes =
| 'image/apng'
| 'image/bmp'
| 'image/gif'
| 'image/jpeg'
| 'image/pjpeg'
| 'image/png'
| 'image/svg+xml'
| 'image/tiff'
| 'image/webp'
| 'image/x-icon'
// 接受父组件参数
const props = defineProps({
modelValue: propTypes.string.def(''),
updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true
disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false
fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M
fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"]
height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px
width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px
borderRadius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px
})
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
// 生成组件唯一id
const uuid = ref('id-' + generateUUID())
// 查看图片
const imgViewVisible = ref(false)
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: propTypes.oneOfType([String, Object, Array]),
title: propTypes.string.def('图片上传'),
updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
fileType: propTypes.array.def(['jpg', 'png', 'gif', 'jpeg']), // 文件类型, 例如['png', 'jpg', 'jpeg']
fileSize: propTypes.number.def(5), // 大小限制(MB)
limit: propTypes.number.def(1), // 数量限制
isShowTip: propTypes.bool.def(false) // 是否显示提示
})
// ========== 上传相关 ==========
const valueRef = ref(props.modelValue)
const uploadRef = ref<UploadInstance>()
const uploadList = ref<UploadUserFile[]>([])
const fileList = ref<UploadUserFile[]>([])
const uploadNumber = ref<number>(0)
const dialogImageUrl = ref()
const dialogVisible = ref(false)
const deleteImg = () => {
emit('update:modelValue', '')
}
const uploadHeaders = ref({
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
})
watch(
() => props.modelValue,
(val) => {
if (val) {
// 首先将值转为数组, 当只穿了一个图片时会报map方法错误
const list = Array.isArray(props.modelValue)
? props.modelValue
: Array.isArray(props.modelValue?.split(','))
? props.modelValue?.split(',')
: Array.of(props.modelValue)
// 然后将数组转为对象数组
fileList.value = list.map((item) => {
if (typeof item === 'string') {
// edit by 芋道源码
item = { name: item, url: item }
}
return item
})
} else {
fileList.value = []
return []
}
},
{
deep: true,
immediate: true
}
)
// 文件上传之前判断
const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
if (fileList.value.length >= props.limit) {
message.error(`上传文件数量不能超过${props.limit}个!`)
return false
}
let fileExtension = ''
if (file.name.lastIndexOf('.') > -1) {
fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1)
}
const isImg = props.fileType.some((type: string) => {
if (file.type.indexOf(type) > -1) return true
return !!(fileExtension && fileExtension.indexOf(type) > -1)
})
const isLimit = file.size < props.fileSize * 1024 * 1024
if (!isImg) {
message.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式!`)
return false
}
if (!isLimit) {
message.error(`上传文件大小不能超过${props.fileSize}MB!`)
return false
}
message.success('正在上传文件,请稍候...')
uploadNumber.value++
const editImg = () => {
const dom = document.querySelector(`#${uuid.value} .el-upload__input`)
dom && dom.dispatchEvent(new MouseEvent('click'))
}
// 处理上传的文件发生变化
// const handleFileChange = (uploadFile: UploadFile): void => {
// uploadRef.value.data.path = uploadFile.name
// }
// 文件上传成功
const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => {
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
const imgType = props.fileType
if (!imgType.includes(rawFile.type as FileTypes))
ElNotification({
title: '温馨提示',
message: '上传图片不符合所需的格式!',
type: 'warning'
})
if (!imgSize)
ElNotification({
title: '温馨提示',
message: `上传图片大小不能超过 ${props.fileSize}M`,
type: 'warning'
})
return imgType.includes(rawFile.type as FileTypes) && imgSize
}
// 图片上传成功提示
const uploadSuccess: UploadProps['onSuccess'] = (res: any): void => {
message.success('上传成功')
uploadList.value.push({ name: res.data, url: res.data })
if (uploadList.value.length == uploadNumber.value) {
fileList.value = fileList.value.concat(uploadList.value)
uploadList.value = []
uploadNumber.value = 0
emit('update:modelValue', listToString(fileList.value))
}
emit('update:modelValue', res.data)
}
// 文件数超出提示
const handleExceed: UploadProps['onExceed'] = (): void => {
message.error(`上传文件数量不能超过${props.limit}个!`)
}
// 上传错误提示
const excelUploadError: UploadProps['onError'] = (): void => {
message.error('导入数据失败,请您重新上传!')
}
// 删除上传文件
const handleRemove = (file) => {
const findex = fileList.value.map((f) => f.name).indexOf(file.name)
if (findex > -1) {
fileList.value.splice(findex, 1)
emit('update:modelValue', listToString(fileList.value))
}
}
// 对象转成指定字符串分隔
const listToString = (list: UploadUserFile[], separator?: string) => {
let strs = ''
separator = separator || ','
for (let i in list) {
strs += list[i].url + separator
}
return strs != '' ? strs.substr(0, strs.length - 1) : ''
}
// 预览
const handlePictureCardPreview: UploadProps['onPreview'] = (file) => {
dialogImageUrl.value = file.url
dialogVisible.value = true
// 图片上传错误提示
const uploadError = () => {
ElNotification({
title: '温馨提示',
message: '图片上传失败,请您重新上传!',
type: 'error'
})
}
</script>
<style scoped lang="scss">
// .el-upload--picture-card 控制加号部分
:deep(.hide .el-upload--picture-card) {
display: none;
.is-error {
.upload {
:deep(.el-upload),
:deep(.el-upload-dragger) {
border: 1px dashed var(--el-color-danger) !important;
&:hover {
border-color: var(--el-color-primary) !important;
}
}
}
}
// 去掉动画效果
:deep(.el-list-enter-active, .el-list-leave-active) {
transition: all 0s;
:deep(.disabled) {
.el-upload,
.el-upload-dragger {
cursor: not-allowed !important;
background: var(--el-disabled-bg-color);
border: 1px dashed var(--el-border-color-darker) !important;
&:hover {
border: 1px dashed var(--el-border-color-darker) !important;
}
}
}
:deep(.el-list-enter, .el-list-leave-active) {
opacity: 0;
transform: translateY(0);
.upload-box {
.no-border {
:deep(.el-upload) {
border: none !important;
}
}
:deep(.upload) {
.el-upload {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: v-bind(width);
height: v-bind(height);
overflow: hidden;
border: 1px dashed var(--el-border-color-darker);
border-radius: v-bind(borderRadius);
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
.upload-handle {
opacity: 1;
}
}
.el-upload-dragger {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
background-color: transparent;
border: 1px dashed var(--el-border-color-darker);
border-radius: v-bind(borderRadius);
&:hover {
border: 1px dashed var(--el-color-primary);
}
}
.el-upload-dragger.is-dragover {
background-color: var(--el-color-primary-light-9);
border: 2px dashed var(--el-color-primary) !important;
}
.upload-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.upload-empty {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 30px;
color: var(--el-color-info);
.el-icon {
font-size: 28px;
color: var(--el-text-color-secondary);
}
}
.upload-handle {
position: absolute;
top: 0;
right: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
background: rgb(0 0 0 / 60%);
opacity: 0;
transition: var(--el-transition-duration-fast);
.handle-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 6%;
color: aliceblue;
.el-icon {
margin-bottom: 40%;
font-size: 130%;
line-height: 130%;
}
span {
font-size: 85%;
line-height: 85%;
}
}
}
}
}
.el-upload__tip {
line-height: 18px;
text-align: center;
}
}
</style>

View File

@ -0,0 +1,277 @@
<template>
<div class="upload-box">
<el-upload
:action="updateUrl"
list-type="picture-card"
:class="['upload', drag ? 'no-border' : '']"
v-model:file-list="fileList"
:multiple="true"
:limit="limit"
:headers="uploadHeaders"
:before-upload="beforeUpload"
:on-exceed="handleExceed"
:on-success="uploadSuccess"
:on-error="uploadError"
:drag="drag"
:accept="fileType.join(',')"
>
<div class="upload-empty">
<slot name="empty">
<Icon icon="ep:plus" />
<!-- <span>请上传图片</span> -->
</slot>
</div>
<template #file="{ file }">
<img :src="file.url" class="upload-image" />
<div class="upload-handle" @click.stop>
<div class="handle-icon" @click="handlePictureCardPreview(file)">
<Icon icon="ep:zoom-in" />
<span>查看</span>
</div>
<div class="handle-icon" @click="handleRemove(file)">
<Icon icon="ep:delete" />
<span>删除</span>
</div>
</div>
</template>
</el-upload>
<div class="el-upload__tip">
<slot name="tip"></slot>
</div>
<el-image-viewer
v-if="imgViewVisible"
@close="imgViewVisible = false"
:url-list="[viewImageUrl]"
/>
</div>
</template>
<script setup lang="ts" name="UploadImgs">
import { PropType, ref } from 'vue'
import { ElUpload, ElNotification, ElImageViewer } from 'element-plus'
import type { UploadProps, UploadFile, UploadUserFile } from 'element-plus'
import { useMessage } from '@/hooks/web/useMessage'
import { propTypes } from '@/utils/propTypes'
import { getAccessToken, getTenantId } from '@/utils/auth'
const message = useMessage() // 消息弹窗
type FileTypes =
| 'image/apng'
| 'image/bmp'
| 'image/gif'
| 'image/jpeg'
| 'image/pjpeg'
| 'image/png'
| 'image/svg+xml'
| 'image/tiff'
| 'image/webp'
| 'image/x-icon'
const props = defineProps({
modelValue: {
type: Array as PropType<UploadUserFile[]>,
required: true
},
updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
drag: propTypes.bool.def(true), // 是否支持拖拽上传 ==> 非必传(默认为 true
disabled: propTypes.bool.def(false), // 是否禁用上传组件 ==> 非必传(默认为 false
limit: propTypes.number.def(5), // 最大图片上传数 ==> 非必传(默认为 5张
fileSize: propTypes.number.def(5), // 图片大小限制 ==> 非必传(默认为 5M
fileType: propTypes.array.def(['image/jpeg', 'image/png', 'image/gif']), // 图片类型限制 ==> 非必传(默认为 ["image/jpeg", "image/png", "image/gif"]
height: propTypes.string.def('150px'), // 组件高度 ==> 非必传(默认为 150px
width: propTypes.string.def('150px'), // 组件宽度 ==> 非必传(默认为 150px
borderRadius: propTypes.string.def('8px') // 组件边框圆角 ==> 非必传(默认为 8px
})
const uploadHeaders = ref({
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
})
const fileList = ref<UploadUserFile[]>(props.modelValue)
/**
* @description 文件上传之前判断
* @param rawFile 上传的文件
* */
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
const imgSize = rawFile.size / 1024 / 1024 < props.fileSize
const imgType = props.fileType
if (!imgType.includes(rawFile.type as FileTypes))
ElNotification({
title: '温馨提示',
message: '上传图片不符合所需的格式!',
type: 'warning'
})
if (!imgSize)
ElNotification({
title: '温馨提示',
message: `上传图片大小不能超过 ${props.fileSize}M`,
type: 'warning'
})
return imgType.includes(rawFile.type as FileTypes) && imgSize
}
// 图片上传成功
interface UploadEmits {
(e: 'update:modelValue', value: UploadUserFile[]): void
}
const emit = defineEmits<UploadEmits>()
const uploadSuccess = (response, uploadFile: UploadFile) => {
if (!response) return
uploadFile.url = response.data
emit('update:modelValue', fileList.value)
message.success('上传成功')
}
// 删除图片
const handleRemove = (uploadFile: UploadFile) => {
fileList.value = fileList.value.filter(
(item) => item.url !== uploadFile.url || item.name !== uploadFile.name
)
emit('update:modelValue', fileList.value)
}
// 图片上传错误提示
const uploadError = () => {
ElNotification({
title: '温馨提示',
message: '图片上传失败,请您重新上传!',
type: 'error'
})
}
// 文件数超出提示
const handleExceed = () => {
ElNotification({
title: '温馨提示',
message: `当前最多只能上传 ${props.limit} 张图片,请移除后上传!`,
type: 'warning'
})
}
// 图片预览
const viewImageUrl = ref('')
const imgViewVisible = ref(false)
const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
viewImageUrl.value = uploadFile.url!
imgViewVisible.value = true
}
</script>
<style scoped lang="scss">
.is-error {
.upload {
:deep(.el-upload--picture-card),
:deep(.el-upload-dragger) {
border: 1px dashed var(--el-color-danger) !important;
&:hover {
border-color: var(--el-color-primary) !important;
}
}
}
}
:deep(.disabled) {
.el-upload--picture-card,
.el-upload-dragger {
cursor: not-allowed;
background: var(--el-disabled-bg-color) !important;
border: 1px dashed var(--el-border-color-darker);
&:hover {
border-color: var(--el-border-color-darker) !important;
}
}
}
.upload-box {
.no-border {
:deep(.el-upload--picture-card) {
border: none !important;
}
}
:deep(.upload) {
.el-upload-dragger {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
border: 1px dashed var(--el-border-color-darker);
border-radius: v-bind(borderRadius);
&:hover {
border: 1px dashed var(--el-color-primary);
}
}
.el-upload-dragger.is-dragover {
background-color: var(--el-color-primary-light-9);
border: 2px dashed var(--el-color-primary) !important;
}
.el-upload-list__item,
.el-upload--picture-card {
width: v-bind(width);
height: v-bind(height);
background-color: transparent;
border-radius: v-bind(borderRadius);
}
.upload-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.upload-handle {
position: absolute;
top: 0;
right: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
background: rgb(0 0 0 / 60%);
opacity: 0;
transition: var(--el-transition-duration-fast);
.handle-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 6%;
color: aliceblue;
.el-icon {
margin-bottom: 15%;
font-size: 140%;
}
span {
font-size: 100%;
}
}
}
.el-upload-list__item {
&:hover {
.upload-handle {
opacity: 1;
}
}
}
.upload-empty {
display: flex;
flex-direction: column;
align-items: center;
font-size: 12px;
line-height: 30px;
color: var(--el-color-info);
.el-icon {
font-size: 28px;
color: var(--el-text-color-secondary);
}
}
}
.el-upload__tip {
line-height: 15px;
text-align: center;
}
}
</style>

View File

@ -44,7 +44,7 @@ export default {
},
upload: async <T = any>(option: any) => {
option.headersType = 'multipart/form-data'
const res = await request({ method: 'PUT', ...option })
const res = await request({ method: 'POST', ...option })
return res as unknown as Promise<T>
}
}

View File

@ -1,6 +1,9 @@
import { reactive } from 'vue'
import { AxiosPromise } from 'axios'
import { findIndex } from '@/utils'
import { eachTree, treeMap, filter } from '@/utils/tree'
import { getBoolDictOptions, getDictOptions, getIntDictOptions } from '@/utils/dict'
import { useI18n } from '@/hooks/web/useI18n'
import { FormSchema } from '@/types/form'
import { TableColumn } from '@/types/table'
import { DescriptionsSchema } from '@/types/descriptions'
@ -23,6 +26,8 @@ export type CrudSchema = Omit<TableColumn, 'children'> & {
type CrudSearchParams = {
// 是否显示在查询项
show?: boolean
// 接口
api?: () => Promise<any>
} & Omit<FormSchema, 'field'>
type CrudTableParams = {
@ -33,6 +38,8 @@ type CrudTableParams = {
type CrudFormParams = {
// 是否显示表单项
show?: boolean
// 接口
api?: () => Promise<any>
} & Omit<FormSchema, 'field'>
type CrudDescriptionsParams = {
@ -47,6 +54,8 @@ interface AllSchemas {
detailSchema: DescriptionsSchema[]
}
const { t } = useI18n()
// 过滤所有结构
export const useCrudSchemas = (
crudSchema: CrudSchema[]
@ -61,13 +70,13 @@ export const useCrudSchemas = (
detailSchema: []
})
const searchSchema = filterSearchSchema(crudSchema)
const searchSchema = filterSearchSchema(crudSchema, allSchemas)
allSchemas.searchSchema = searchSchema || []
const tableColumns = filterTableSchema(crudSchema)
allSchemas.tableColumns = tableColumns || []
const formSchema = filterFormSchema(crudSchema)
const formSchema = filterFormSchema(crudSchema, allSchemas)
allSchemas.formSchema = formSchema
const detailSchema = filterDescriptionsSchema(crudSchema)
@ -79,9 +88,11 @@ export const useCrudSchemas = (
}
// 过滤 Search 结构
const filterSearchSchema = (crudSchema: CrudSchema[]): FormSchema[] => {
const filterSearchSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
const searchSchema: FormSchema[] = []
// 获取字典列表队列
const searchRequestTask: Array<() => Promise<void>> = []
eachTree(crudSchema, (schemaItem: CrudSchema) => {
// 判断是否显示
if (schemaItem?.isSearch || schemaItem.search?.show) {
@ -107,12 +118,31 @@ const filterSearchSchema = (crudSchema: CrudSchema[]): FormSchema[] => {
field: schemaItem.field,
label: schemaItem.search?.label || schemaItem.label
}
if (searchSchemaItem.api) {
searchRequestTask.push(async () => {
const res = await (searchSchemaItem.api as () => AxiosPromise)()
if (res) {
const index = findIndex(allSchemas.searchSchema, (v: FormSchema) => {
return v.field === searchSchemaItem.field
})
if (index !== -1) {
allSchemas.searchSchema[index]!.componentProps!.options = filterOptions(
res,
searchSchemaItem.componentProps.optionsAlias?.labelField
)
}
}
})
}
// 删除不必要的字段
delete searchSchemaItem.show
searchSchema.push(searchSchemaItem)
}
})
for (const task of searchRequestTask) {
task()
}
return searchSchema
}
@ -139,9 +169,12 @@ const filterTableSchema = (crudSchema: CrudSchema[]): TableColumn[] => {
}
// 过滤 form 结构
const filterFormSchema = (crudSchema: CrudSchema[]): FormSchema[] => {
const filterFormSchema = (crudSchema: CrudSchema[], allSchemas: AllSchemas): FormSchema[] => {
const formSchema: FormSchema[] = []
// 获取字典列表队列
const formRequestTask: Array<() => Promise<void>> = []
eachTree(crudSchema, (schemaItem: CrudSchema) => {
// 判断是否显示
if (schemaItem?.isForm !== false && schemaItem?.form?.show !== false) {
@ -185,6 +218,23 @@ const filterFormSchema = (crudSchema: CrudSchema[]): FormSchema[] => {
label: schemaItem.form?.label || schemaItem.label
}
if (formSchemaItem.api) {
formRequestTask.push(async () => {
const res = await (formSchemaItem.api as () => AxiosPromise)()
if (res) {
const index = findIndex(allSchemas.formSchema, (v: FormSchema) => {
return v.field === formSchemaItem.field
})
if (index !== -1) {
allSchemas.formSchema[index]!.componentProps!.options = filterOptions(
res,
formSchemaItem.componentProps.optionsAlias?.labelField
)
}
}
})
}
// 删除不必要的字段
delete formSchemaItem.show
@ -192,6 +242,9 @@ const filterFormSchema = (crudSchema: CrudSchema[]): FormSchema[] => {
}
})
for (const task of formRequestTask) {
task()
}
return formSchema
}
@ -225,3 +278,15 @@ const filterDescriptionsSchema = (crudSchema: CrudSchema[]): DescriptionsSchema[
return descriptionsSchema
}
// 给options添加国际化
const filterOptions = (options: Recordable, labelField?: string) => {
return options.map((v: Recordable) => {
if (labelField) {
v['labelField'] = t(v.labelField)
} else {
v['label'] = t(v.label)
}
return v
})
}

View File

@ -63,7 +63,7 @@ type CrudDescriptionsParams = {
} & Omit<DescriptionsSchema, 'field'>
type CrudPrintParams = {
// 是否显示表单
// 是否显示打印
show?: boolean
} & Omit<VxeTableDefines.ColumnInfo[], 'field'>

View File

@ -53,7 +53,8 @@ export default defineComponent({
<ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}>
{meta?.icon && breadcrumbIcon.value ? (
<>
<Icon icon={meta.icon} class="mr-[5px]"></Icon> {t(v?.meta?.title)}
<Icon icon={meta.icon} class="mr-[2px]" svgClass="inline-block"></Icon>
{t(v?.meta?.title)}
</>
) : (
t(v?.meta?.title)

View File

@ -55,31 +55,33 @@ watch(
</script>
<template>
<router-link
:class="[
prefixCls,
layout !== 'classic' ? `${prefixCls}__Top` : '',
'flex !h-[var(--logo-height)] items-center cursor-pointer justify-center relative',
'dark:bg-[var(--el-bg-color)]'
]"
to="/"
>
<img
src="@/assets/imgs/logo.png"
class="w-[calc(var(--logo-height)-10px)] h-[calc(var(--logo-height)-10px)]"
/>
<div
v-if="show"
<div>
<router-link
:class="[
'ml-10px text-16px font-700',
{
'text-[var(--logo-title-text-color)]': layout === 'classic',
'text-[var(--top-header-text-color)]':
layout === 'topLeft' || layout === 'top' || layout === 'cutMenu'
}
prefixCls,
layout !== 'classic' ? `${prefixCls}__Top` : '',
'flex !h-[var(--logo-height)] items-center cursor-pointer justify-center relative',
'dark:bg-[var(--el-bg-color)]'
]"
to="/"
>
{{ title }}
</div>
</router-link>
<img
src="@/assets/imgs/logo.png"
class="w-[calc(var(--logo-height)-10px)] h-[calc(var(--logo-height)-10px)]"
/>
<div
v-if="show"
:class="[
'ml-10px text-16px font-700',
{
'text-[var(--logo-title-text-color)]': layout === 'classic',
'text-[var(--top-header-text-color)]':
layout === 'topLeft' || layout === 'top' || layout === 'cutMenu'
}
]"
>
{{ title }}
</div>
</router-link>
</div>
</template>

View File

@ -161,6 +161,7 @@ $prefix-cls: #{$namespace}-menu;
// 设置子菜单悬停的高亮和背景色
.#{$elNamespace}-sub-menu__title,
.#{$elNamespace}-menu-item {
height: 59px;
&:hover {
color: var(--left-menu-text-active-color) !important;
background-color: var(--left-menu-bg-color) !important;
@ -170,6 +171,7 @@ $prefix-cls: #{$namespace}-menu;
// 设置选中时的高亮背景和高亮颜色
.#{$elNamespace}-sub-menu.is-active,
.#{$elNamespace}-menu-item.is-active {
height: 59px;
color: var(--left-menu-text-active-color) !important;
background-color: var(--left-menu-bg-active-color) !important;

View File

@ -426,5 +426,19 @@ export default {
cfPwdMsg: 'Please Enter Confirm Password',
diffPwd: 'The Passwords Entered Twice No Match'
}
},
cropper: {
selectImage: 'Select Image',
uploadSuccess: 'Uploaded success!',
modalTitle: 'Avatar upload',
okText: 'Confirm and upload',
btn_reset: 'Reset',
btn_rotate_left: 'Counterclockwise rotation',
btn_rotate_right: 'Clockwise rotation',
btn_scale_x: 'Flip horizontal',
btn_scale_y: 'Flip vertical',
btn_zoom_in: 'Zoom in',
btn_zoom_out: 'Zoom out',
preview: 'Preivew'
}
}

View File

@ -419,5 +419,19 @@ export default {
pwdRules: '长度在 6 到 20 个字符',
diffPwd: '两次输入密码不一致'
}
},
cropper: {
selectImage: '选择图片',
uploadSuccess: '上传成功',
modalTitle: '头像上传',
okText: '确认并上传',
btn_reset: '重置',
btn_rotate_left: '逆时针旋转',
btn_rotate_right: '顺时针旋转',
btn_scale_x: '水平翻转',
btn_scale_y: '垂直翻转',
btn_zoom_in: '放大',
btn_zoom_out: '缩小',
preview: '预览'
}
}

View File

@ -56,7 +56,7 @@ watch(
import('./theme/light.scss')
}
},
{ immediate: true }
{ deep: true }
)
// 全局默认参数
VXETable.setup({

View File

@ -14,7 +14,7 @@ import { usePermissionStoreWithOut } from '@/store/modules/permission'
import { getInfoApi } from '@/api/login'
import { listSimpleDictDataApi } from '@/api/system/dict/dict.data'
const { wsCache } = useCache('sessionStorage')
const { wsCache } = useCache()
const { start, done } = useNProgress()

View File

@ -64,7 +64,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
meta: {
title: t('router.home'),
icon: 'ep:home-filled',
noCache: true,
noCache: false,
affix: true
}
}
@ -85,7 +85,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
meta: {
canTo: true,
hidden: true,
noTagsView: true,
noTagsView: false,
icon: 'ep:user',
title: t('common.profile')
}

View File

@ -22,6 +22,7 @@ export type ComponentName =
| 'InputPassword'
| 'Editor'
| 'UploadImg'
| 'UploadImgs'
| 'UploadFile'
export type ColProps = {

View File

@ -69,7 +69,7 @@ export const trim = (str: string) => {
* @param {Date | number | string} time 需要转换的时间
* @param {String} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss
*/
export function formatTime(time: Date | number | string, fmt: string) {
export const formatTime = (time: Date | number | string, fmt: string) => {
if (!time) return ''
else {
const date = new Date(time)
@ -100,7 +100,7 @@ export function formatTime(time: Date | number | string, fmt: string) {
/**
* 生成随机字符串
*/
export function toAnyString() {
export const toAnyString = () => {
const str: string = 'xxxxx-xxxxx-4xxxx-yxxxx-xxxxx'.replace(/[xy]/g, (c: string) => {
const r: number = (Math.random() * 16) | 0
const v: number = c === 'x' ? r : (r & 0x3) | 0x8
@ -108,3 +108,34 @@ export function toAnyString() {
})
return str
}
export const generateUUID = () => {
if (typeof crypto === 'object') {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
const callback = (c: any) => {
const num = Number(c)
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(
16
)
}
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback)
}
}
let timestamp = new Date().getTime()
let performanceNow =
(typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
let random = Math.random() * 16
if (timestamp > 0) {
random = (timestamp + random) % 16 | 0
timestamp = Math.floor(timestamp / 16)
} else {
random = (performanceNow + random) % 16 | 0
performanceNow = Math.floor(performanceNow / 16)
}
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16)
})
}

View File

@ -56,7 +56,13 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
title: route.name,
icon: route.icon,
hidden: !route.visible,
noCache: !route.keepAlive
noCache: !route.keepAlive,
alwaysShow:
route.children &&
route.children.length === 1 &&
import.meta.env.VITE_ROUTE_ALWAYSSHOW_ENABLE === 'true'
? true
: false
}
// 路由地址转首字母大写驼峰作为路由名称适配keepAlive
let data: AppRouteRecordRaw = {
@ -71,6 +77,7 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
data.meta = {}
data.name = toCamelCase(route.path, true) + 'Parent'
data.redirect = ''
meta.alwaysShow = true
const childrenData: AppRouteRecordRaw = {
path: '',
name: toCamelCase(route.path, true),

View File

@ -1,245 +1,40 @@
<template>
<div class="user-info-head" @click="editCropper()">
<img :src="props.img" title="点击上传头像" class="img-circle img-lg" alt="" />
<div class="change-avatar">
<CropperAvatar
ref="cropperRef"
:value="avatar"
:showBtn="false"
@change="handelUpload"
:btnProps="{ preIcon: 'ant-design:cloud-upload-outlined' }"
width="120px"
/>
</div>
<el-dialog
v-model="dialogVisible"
title="编辑头像"
:mask-closable="false"
width="800px"
append-to-body
@opened="cropperVisible = true"
>
<el-row>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<VueCropper
ref="cropper"
v-if="cropperVisible"
:img="options.img"
:info="true"
:infoTrue="options.infoTrue"
:autoCrop="options.autoCrop"
:autoCropWidth="options.autoCropWidth"
:autoCropHeight="options.autoCropHeight"
:fixedNumber="options.fixedNumber"
:fixedBox="options.fixedBox"
:centerBox="options.centerBox"
@real-time="realTime"
/>
</el-col>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div
class="avatar-upload-preview"
:style="{
width: previews.w + 'px',
height: previews.h + 'px',
overflow: 'hidden',
margin: '5px'
}"
>
<div :style="previews.div">
<img :src="previews.url" :style="previews.img" style="!max-width: 100%" alt="" />
</div>
</div>
</el-col>
</el-row>
<template #footer>
<el-row>
<el-col :lg="2" :md="2">
<el-upload
action="#"
:http-request="requestUpload"
:show-file-list="false"
:before-upload="beforeUpload"
>
<el-button size="small">
<Icon icon="ep:upload-filled" class="mr-5px" />
选择
</el-button>
</el-upload>
</el-col>
<el-col :lg="{ span: 1, offset: 2 }" :md="2">
<el-button size="small" @click="changeScale(1)">
<Icon icon="ep:zoom-in" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button size="small" @click="changeScale(-1)">
<Icon icon="ep:zoom-out" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button size="small" @click="rotateLeft()">
<Icon icon="ep:arrow-left-bold" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button size="small" @click="rotateRight()">
<Icon icon="ep:arrow-right-bold" class="mr-5px" />
</el-button>
</el-col>
<el-col :lg="{ span: 2, offset: 6 }" :md="2">
<el-button size="small" type="primary" @click="uploadImg()"> </el-button>
</el-col>
</el-row>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, Ref, UnwrapNestedRefs } from 'vue'
import VueCropper from 'vue-cropper/lib/vue-cropper.vue'
import 'vue-cropper/dist/index.css'
import { ElRow, ElCol, ElUpload, ElMessage, ElDialog } from 'element-plus'
import { computed, ref } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { CropperAvatar } from '@/components/Cropper'
import { uploadAvatarApi } from '@/api/system/user/profile'
const cropper = ref()
const dialogVisible = ref(false)
const cropperVisible = ref(false)
const props = defineProps({
img: propTypes.string.def('')
})
interface Options {
img: string | ArrayBuffer | null // 裁剪图片的地址
info: true // 裁剪框的大小信息
outputSize: number // 裁剪生成图片的质量 [1至0.1]
outputType: 'jpeg' // 裁剪生成图片的格式
canScale: boolean // 图片是否允许滚轮缩放
autoCrop: boolean // 是否默认生成截图框
autoCropWidth: number // 默认生成截图框宽度
autoCropHeight: number // 默认生成截图框高度
fixedBox: boolean // 固定截图框大小 不允许改变
fixed: boolean // 是否开启截图框宽高固定比例
fixedNumber: Array<number> // 截图框的宽高比例 需要配合centerBox一起使用才能生效
full: boolean // 是否输出原图比例的截图
canMoveBox: boolean // 截图框能否拖动
original: boolean // 上传图片按照原始比例渲染
centerBox: boolean // 截图框是否被限制在图片里面
infoTrue: boolean // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
}
const options: UnwrapNestedRefs<Options> = reactive({
img: '', // 需要剪裁的图片
autoCrop: true, // 是否默认生成截图框
autoCropWidth: 200, // 默认生成截图框的宽度
autoCropHeight: 200, // 默认生成截图框的长度
fixedBox: false, // 是否固定截图框的大小 不允许改变
info: true, // 裁剪框的大小信息
outputSize: 1, // 裁剪生成图片的质量 [1至0.1]
outputType: 'jpeg', // 裁剪生成图片的格式
canScale: false, // 图片是否允许滚轮缩放
fixed: true, // 是否开启截图框宽高固定比例
fixedNumber: [1, 1], // 截图框的宽高比例 需要配合centerBox一起使用才能生效
full: true, // 是否输出原图比例的截图
canMoveBox: false, // 截图框能否拖动
original: false, // 上传图片按照原始比例渲染
centerBox: true, // 截图框是否被限制在图片里面
infoTrue: true // true 为展示真实输出图片宽高 false 展示看到的截图框宽高
const avatar = computed(() => {
return props.img
})
const previews: Ref<any> = ref({})
/** 编辑头像 */
const editCropper = () => {
dialogVisible.value = true
const cropperRef = ref()
const handelUpload = async ({ data }) => {
await uploadAvatarApi({ avatarFile: data })
cropperRef.value.close()
}
/** 向左旋转 */
const rotateLeft = () => {
cropper.value.rotateLeft()
}
/** 向右旋转 */
const rotateRight = () => {
cropper.value.rotateRight()
}
/** 图片缩放 */
const changeScale = (num: number) => {
num = num || 1
cropper.value.changeScale(num)
}
// 覆盖默认的上传行为
const requestUpload: any = () => {}
/** 上传预处理 */
const beforeUpload = (file: Blob) => {
if (file.type.indexOf('image/') == -1) {
ElMessage('文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。')
} else {
const reader = new FileReader()
// 转化为base64
reader.readAsDataURL(file)
reader.onload = () => {
if (reader.result) {
// 获取到需要剪裁的图片 展示到剪裁框中
options.img = reader.result as string
}
return false
}
}
}
/** 上传图片 */
const uploadImg = () => {
cropper.value.getCropBlob((data: any) => {
let formData = new FormData()
formData.append('avatarFile', data)
uploadAvatarApi(formData).then((res) => {
options.img = res
window.location.reload()
})
dialogVisible.value = false
cropperVisible.value = false
})
}
/** 实时预览 */
const realTime = (data: any) => {
previews.value = data
}
watch(
() => props.img,
() => {
if (props.img) {
options.img = props.img
previews.value.img = props.img
previews.value.url = props.img
}
}
)
</script>
<style scoped>
.user-info-head {
position: relative;
display: inline-block;
}
.img-circle {
border-radius: 50%;
}
.img-lg {
width: 120px;
height: 120px;
}
.avatar-upload-preview {
position: absolute;
top: 50%;
-webkit-transform: translate(50%, -50%);
transform: translate(50%, -50%);
width: 200px;
height: 200px;
border-radius: 50%;
-webkit-box-shadow: 0 0 4px #ccc;
box-shadow: 0 0 4px #ccc;
overflow: hidden;
}
.user-info-head:hover:after {
content: '+';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
line-height: 110px;
border-radius: 50%;
<style scoped lang="scss">
.change-avatar {
img {
display: block;
margin-bottom: 15px;
border-radius: 50%;
}
}
</style>

View File

@ -95,9 +95,8 @@ const handleProcessClick = (
message
.confirm('确认标记为' + type + '?', t('common.reminder'))
.then(async () => {
ApiErrorLogApi.updateApiErrorLogPageApi(row.id, processSttatus).then(() => {
message.success(t('common.updateSuccess'))
})
await ApiErrorLogApi.updateApiErrorLogPageApi(row.id, processSttatus)
message.success(t('common.updateSuccess'))
})
.finally(async () => {
// 刷新列表

View File

@ -64,8 +64,8 @@
<el-form-item label="存储器" prop="storage">
<el-select v-model="form.storage" placeholder="请选择存储器" :disabled="form.id !== 0">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
:key="dict.value"
v-for="(dict, index) in getIntDictOptions(DICT_TYPE.INFRA_FILE_STORAGE)"
:key="index"
:label="dict.label"
:value="dict.value"
/>
@ -197,7 +197,7 @@ const dialogVisible = ref(false) // 是否显示弹出层
const dialogTitle = ref('edit') // 弹出层标题
const formRef = ref<FormInstance>() // 表单 Ref
const detailData = ref() // 详情 Ref
let form = ref<FileConfigApi.FileConfigVO>({
const form = ref<FileConfigApi.FileConfigVO>({
id: 0,
name: '',
storage: 0,
@ -230,6 +230,28 @@ const setDialogTile = (type: string) => {
const handleCreate = (formEl: FormInstance | undefined) => {
setDialogTile('create')
formEl?.resetFields()
form.value = {
id: 0,
name: '',
storage: 0,
master: false,
visible: false,
config: {
basePath: '',
host: '',
port: 0,
username: '',
password: '',
mode: '',
endpoint: '',
bucket: '',
accessKey: '',
accessSecret: '',
domain: ''
},
remark: '',
createTime: new Date()
}
}
// 修改操作

View File

@ -12,8 +12,7 @@ export const rules = reactive({
email: [required],
phone: [
{
min: 11,
max: 11,
len: 11,
trigger: 'blur',
message: '请输入正确的手机号码'
}

View File

@ -75,16 +75,15 @@
</XModal>
</template>
<script setup lang="ts" name="Dept">
import { nextTick, onMounted, reactive, ref, unref } from 'vue'
import { nextTick, onMounted, ref, unref } from 'vue'
import { ElSelect, ElTreeSelect, ElOption } from 'element-plus'
import { VxeGridInstance } from 'vxe-table'
import { handleTree, defaultProps } from '@/utils/tree'
import { required } from '@/utils/formRules.js'
import { useI18n } from '@/hooks/web/useI18n'
import { useMessage } from '@/hooks/web/useMessage'
import { useVxeGrid } from '@/hooks/web/useVxeGrid'
import { FormExpose } from '@/components/Form'
import { allSchemas } from './dept.data'
import { allSchemas, rules } from './dept.data'
import * as DeptApi from '@/api/system/dept'
import { getListSimpleUsersApi, UserVO } from '@/api/system/user'
@ -107,13 +106,6 @@ const actionLoading = ref(false) // 遮罩层
const formRef = ref<FormExpose>() // 表单 Ref
const deptOptions = ref() // 树形结构
const userOption = ref<UserVO[]>([])
// 新增和修改的表单校验
const rules = reactive({
name: [required],
sort: [required],
path: [required],
status: [required]
})
const getUserList = async () => {
const res = await getListSimpleUsersApi()

View File

@ -1,34 +1,8 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form :model="queryParams" ref="queryForm" :inline="true">
<el-form-item label="菜单名称" prop="name">
<el-input v-model="queryParams.name" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择菜单状态">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<!-- 操作搜索 -->
<XButton
type="primary"
preIcon="ep:search"
:title="t('common.query')"
@click="handleQuery()"
/>
<!-- 操作重置 -->
<XButton preIcon="ep:refresh-right" :title="t('common.reset')" @click="resetQuery()" />
</el-form-item>
</el-form>
<vxe-toolbar>
<template #buttons>
<!-- 列表 -->
<vxe-grid ref="xGrid" v-bind="gridOptions" show-overflow class="xtable-scrollbar">
<template #toolbar_buttons>
<!-- 操作新增 -->
<XButton
type="primary"
@ -37,63 +11,30 @@
v-hasPermi="['system:menu:create']"
@click="handleCreate()"
/>
<XButton title="展开所有" @click="xTable?.setAllTreeExpand(true)" />
<XButton title="关闭所有" @click="xTable?.clearTreeExpand()" />
<XButton title="展开所有" @click="xGrid?.setAllTreeExpand(true)" />
<XButton title="关闭所有" @click="xGrid?.clearTreeExpand()" />
</template>
</vxe-toolbar>
<!-- 列表 -->
<vxe-table
show-overflow
keep-source
ref="xTable"
:loading="tableLoading"
:row-config="{ keyField: 'id' }"
:column-config="{ resizable: true }"
:tree-config="{ transform: true, rowField: 'id', parentField: 'parentId' }"
:print-config="{}"
:export-config="{}"
:data="tableData"
>
<vxe-column title="菜单名称" field="name" width="200" tree-node>
<template #default="{ row }">
<Icon :icon="row.icon" />
<span class="ml-3">{{ row.name }}</span>
</template>
</vxe-column>
<vxe-column title="菜单类型" field="type">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.SYSTEM_MENU_TYPE" :value="row.type" />
</template>
</vxe-column>
<vxe-column title="路由地址" field="path" />
<vxe-column title="组件路径" field="component" />
<vxe-column title="权限标识" field="permission" />
<vxe-column title="排序" field="sort" />
<vxe-column title="状态" field="status">
<template #default="{ row }">
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="row.status" />
</template>
</vxe-column>
<vxe-column title="创建时间" field="createTime" formatter="formatDate" />
<vxe-column title="操作" width="200">
<template #default="{ row }">
<!-- 操作修改 -->
<XTextButton
preIcon="ep:edit"
:title="t('action.edit')"
v-hasPermi="['system:menu:update']"
@click="handleUpdate(row.id)"
/>
<!-- 操作删除 -->
<XTextButton
preIcon="ep:delete"
:title="t('action.del')"
v-hasPermi="['system:menu:delete']"
@click="handleDelete(row.id)"
/>
</template>
</vxe-column>
</vxe-table>
<template #name_default="{ row }">
<Icon :icon="row.icon" />
<span class="ml-3">{{ row.name }}</span>
</template>
<template #actionbtns_default="{ row }">
<!-- 操作修改 -->
<XTextButton
preIcon="ep:edit"
:title="t('action.edit')"
v-hasPermi="['system:menu:update']"
@click="handleUpdate(row.id)"
/>
<!-- 操作删除 -->
<XTextButton
preIcon="ep:delete"
:title="t('action.del')"
v-hasPermi="['system:menu:delete']"
@click="handleDelete(row.id)"
/>
</template>
</vxe-grid>
</ContentWrap>
<!-- 添加或修改菜单对话框 -->
<XModal id="menuModel" v-model="dialogVisible" :title="dialogTitle">
@ -124,7 +65,7 @@
<el-radio-group v-model="menuForm.type">
<el-radio-button
v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_MENU_TYPE)"
:key="dict.value"
:key="dict.label"
:label="dict.value"
>
{{ dict.label }}
@ -178,7 +119,7 @@
<el-radio
border
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:key="dict.label"
:label="dict.value"
>
{{ dict.label }}
@ -235,7 +176,7 @@
</template>
<script setup lang="ts" name="Menu">
// 全局相关的 import
import { onMounted, reactive, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { useMessage } from '@/hooks/web/useMessage'
@ -245,9 +186,7 @@ import {
ElFormItem,
ElInput,
ElInputNumber,
ElSelect,
ElTreeSelect,
ElOption,
ElRadio,
ElRadioGroup,
ElRadioButton,
@ -255,21 +194,33 @@ import {
} from 'element-plus'
import { Tooltip } from '@/components/Tooltip'
import { IconSelect } from '@/components/Icon'
import { VxeTableInstance } from 'vxe-table'
import { VxeGridInstance } from 'vxe-table'
// 业务相关的 import
import * as MenuApi from '@/api/system/menu'
import { required } from '@/utils/formRules.js'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { SystemMenuTypeEnum, CommonStatusEnum } from '@/utils/constants'
import { handleTree, defaultProps } from '@/utils/tree'
import * as MenuApi from '@/api/system/menu'
import { allSchemas, rules } from './menu.data'
import { useVxeGrid } from '@/hooks/web/useVxeGrid'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const { wsCache } = useCache()
// 列表相关的变量
const xTable = ref<VxeTableInstance>()
const tableLoading = ref(false)
const tableData = ref()
// 列表相关的变量
const xGrid = ref<VxeGridInstance>() // 列表 Grid Ref
const treeConfig = {
transform: true,
rowField: 'id',
parentField: 'parentId',
expandAll: false
}
const { gridOptions, getList, deleteData } = useVxeGrid<MenuApi.MenuVO>({
allSchemas: allSchemas,
treeConfig: treeConfig,
getListApi: MenuApi.getMenuListApi,
deleteApi: MenuApi.deleteMenuApi
})
// 弹窗相关的变量
const dialogVisible = ref(false) // 是否显示弹出层
const dialogTitle = ref('edit') // 弹出层标题
@ -292,13 +243,6 @@ const menuForm = ref<MenuApi.MenuVO>({
keepAlive: true,
createTime: new Date()
})
// 新增和修改的表单校验
const rules = reactive({
name: [required],
sort: [required],
path: [required],
status: [required]
})
// ========== 下拉框[上级菜单] ==========
const menuOptions = ref<any[]>([]) // 树形结构
@ -311,31 +255,6 @@ const getTree = async () => {
menuOptions.value.push(menu)
}
// ========== 查询 ==========
const queryParams = reactive<MenuApi.MenuPageReqVO>({
name: undefined,
status: undefined
})
// 执行查询
const getList = async () => {
tableLoading.value = true
const res = await MenuApi.getMenuListApi(queryParams)
tableData.value = res
tableLoading.value = false
}
// 查询操作
const handleQuery = async () => {
await getList()
}
// 重置操作
const resetQuery = async () => {
queryParams.name = undefined
queryParams.status = undefined
await getList()
}
// ========== 新增/修改 ==========
// 设置标题
@ -407,7 +326,7 @@ const submitForm = async () => {
actionLoading.value = false
wsCache.delete(CACHE_KEY.ROLE_ROUTERS)
// 操作成功,重新加载列表
await getList()
await getList(xGrid)
}
}
@ -419,15 +338,6 @@ const isExternal = (path: string) => {
// ========== 删除 ==========
// 删除操作
const handleDelete = async (rowId: number) => {
message.delConfirm().then(async () => {
await MenuApi.deleteMenuApi(rowId)
message.success(t('common.delSuccess'))
await getList()
})
await deleteData(xGrid, rowId)
}
// ========== 初始化 ==========
onMounted(async () => {
await getList()
})
</script>

View File

@ -0,0 +1,75 @@
import { reactive } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { DICT_TYPE } from '@/utils/dict'
import { required } from '@/utils/formRules'
import { VxeCrudSchema, useVxeCrudSchemas } from '@/hooks/web/useVxeCrudSchemas'
const { t } = useI18n() // 国际化
// 新增和修改的表单校验
export const rules = reactive({
name: [required],
sort: [required],
path: [required],
status: [required]
})
// CrudSchema
const crudSchemas = reactive<VxeCrudSchema>({
primaryKey: 'id',
primaryType: null,
action: true,
columns: [
{
title: '上级菜单',
field: 'parentId',
isTable: false
},
{
title: '菜单名称',
field: 'name',
isSearch: true,
table: {
treeNode: true,
align: 'left',
width: '200px',
slots: {
default: 'name_default'
}
}
},
{
title: '菜单类型',
field: 'type',
dictType: DICT_TYPE.SYSTEM_MENU_TYPE
},
{
title: '路由地址',
field: 'path'
},
{
title: '组件路径',
field: 'component'
},
{
title: '权限标识',
field: 'permission'
},
{
title: '排序',
field: 'sort'
},
{
title: t('common.status'),
field: 'status',
dictType: DICT_TYPE.COMMON_STATUS,
dictClass: 'number',
isSearch: true
},
{
title: t('common.createTime'),
field: 'createTime',
formatter: 'formatDate'
}
]
})
export const { allSchemas } = useVxeCrudSchemas(crudSchemas)

View File

@ -53,7 +53,7 @@
:data="detailData"
>
<template #content="{ row }">
<Editor :model-value="row.content" read-only="true" />
<Editor :model-value="row.content" :readonly="true" />
</template>
</Descriptions>
<template #footer>

View File

@ -43,10 +43,7 @@ const crudSchemas = reactive<VxeCrudSchema>({
}
},
form: {
component: 'UploadImg',
componentProps: {
limit: 1
}
component: 'UploadImg'
}
},
{

View File

@ -145,7 +145,7 @@
v-for="item in postOptions"
:key="item.id"
:label="item.name"
:value="item.id"
:value="(item.id as unknown as number)"
/>
</el-select>
</template>

View File

@ -10,12 +10,10 @@ export const rules = reactive({
username: [required],
nickname: [required],
email: [required],
postIds: [required],
status: [required],
mobile: [
{
min: 11,
max: 11,
len: 11,
trigger: 'blur',
message: '请输入正确的手机号码'
}

View File

@ -24,7 +24,7 @@
"@/*": ["src/*"]
},
"types": [
"@intlify/vite-plugin-vue-i18n/client",
"@intlify/unplugin-vue-i18n/types",
"vite/client",
"element-plus/global",
"@types/intro.js",

View File

@ -10,7 +10,8 @@ declare module '*.vue' {
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_PORT: number
readonly VITE_OPEN: boolean
readonly VITE_OPEN: string
readonly VITE_ROUTE_ALWAYSSHOW_ENABLE: string
readonly VITE_APP_CAPTCHA_ENABLE: string
readonly VITE_APP_TENANT_ENABLE: string
readonly VITE_BASE_URL: string

View File

@ -30,7 +30,7 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
// 端口号
port: env.VITE_PORT,
host: "0.0.0.0",
open: env.VITE_OPEN,
open: env.VITE_OPEN === 'true',
// 本地跨域代理
proxy: {
['/admin-api']: {